Previous Next Contents

Chapitre 3    Structures de données élémentaires

Dans ce chapitre, nous introduisons quelques structures utilisées de façon très intensive en programmation. Leur but est de gérer un ensemble fini d'éléments dont le nombre n'est pas fixé a priori. Les éléments de cet ensemble peuvent être de différentes sortes: nombres entiers ou réels, chaînes de caractères, ou des objets informatiques plus complexes comme les identificateurs de processus ou les expressions de formules en cours de calcul ... On ne s'intéressera pas aux éléments de l'ensemble en question mais aux opérations que l'on effectue sur cet ensemble, indépendamment de la nature de ses éléments. Ainsi les ensembles que l'on utilise en programmation, contrairement à ceux considérés en mathématiques qui sont fixés une fois pour toutes, sont des objets dynamiques. Le nombre de leurs éléments varie au cours de l'exécution du programme, puisqu'on peut y ajouter et supprimer des éléments en cours de traitement. Plus précisément les opérations que l'on s'autorise sur les ensembles sont les suivantes :

Cette gestion des ensembles doit, pour être efficace, répondre au mieux à deux critères parfois contradictoires: un minimum de place mémoire utilisée et un minimum d'instructions élémentaires pour réaliser une opération. La place mémoire utilisée devrait pour bien faire être très voisine du nombre d'éléments de l'ensemble E, multipliée par leur taille; c'est ce qui se passera pour les trois structures que l'on va étudier plus loin. En ce qui concerne la minimisation du nombre d'instructions élémentaires, on peut tester très simplement si un ensemble est vide et on peut réaliser l'opération d'ajout en quelques instructions, toutefois il est impossible de réaliser une suppression ou une recherche d'un élément quelconque dans un ensemble en utilisant un nombre d'opérations indépendant du cardinal de cet ensemble (à moins d'utiliser une structure demandant une très grande place en mémoire). Pour améliorer l'efficacité, on considère des structures de données dans lesquelles on restreint la portée des opérations de recherche et de suppression d'un élément en se limitant à la réalisation de ces opérations sur le dernier ou le premier élément de l'ensemble, ceci donne les structures de pile ou de file, nous verrons que malgré ces restrictions les structures en question ont de nombreuses applications.

3.1  Listes chaînées

La liste est une structure de base de la programmation, le langage LISP (LISt Processing), conçu par John MacCarthy en 1960, ou sa version plus récente Scheme [1], utilise principalement cette structure qui se révèle utile pour le calcul symbolique. Dans ce qui suit on utilise la liste pour représenter un ensemble d'éléments. Chaque élément est contenu dans une cellule, celle ci contient en plus de l'élément l'adresse de la cellule suivante, appelée aussi pointeur. La recherche d'un élément dans la liste s'apparente à un classique ``jeu de piste'' dont le but est de retrouver un objet caché: on commence par avoir des informations sur un lieu où pourrait se trouver cet objet, en ce lieu on découvre des informations sur un autre lieu où il risque de se trouver et ainsi de suite. Le langage Java permet cette réalisation à l'aide de classes et d'objets: les cellules sont des objets (c'est à dire des instances d'une classe) dont un des champs contient une réference vers la cellule suivante. La référence vers la première cellule est elle contenue dans une variable de tête de liste. Les déclarations correspondantes sont les suivantes, où l'on suppose ici que les éléments stockés dans chaque cellule dont de type entier..

class Liste {

  int contenu;
  Liste suivant;

  Liste (int x, Liste a) {
      contenu = x;
      suivant = a;
  }
}
L'instruction new Liste (x, a) construit une nouvelle cellule dont les champs sont x et a. La fonction Liste(x,a) est un constructeur de la classe Liste (un constructeur est une fonction non statique qui se distingue par le type de son résultat qui est celui de la classe courante et par son absence de nom pour l'identifier). L'objet null appartient à toute classe, et représentera dans le cas des listes le marqueur de fin de liste. Ainsi new Liste(2, new Liste (7, new Liste (11, null))) représentera la liste 2,7,11. Les opérations sur les ensembles que nous avons considérées ci-dessus s'expriment alors comme suit si on gère ceux-ci par des listes:




Figure 3.1 : Ajout d'un élément dans une liste

static boolean estVide (Liste a) {  

  return a == null;
}
La procédure ajouter insère l'élément x en tête de liste. Ce choix de mettre l'élément en tête est indispensable pour que le nombre d'opérations lors de l'ajout soit indépendant de la taille de la liste; il suffit alors de modifier la valeur de la tête de liste ce qui est fait simplement par

static Liste ajouter (int x, Liste a) {

  return new Liste (x, a);   // L'ancienne tête se retrouve après x
}
La fonction recherche, qui vérifie si l'élément x figure bien dans la liste a, effectue un parcours de la liste pour rechercher l'élément. La variable a est modifiée itérativement par a = a.suivant de façon à parcourir tous les éléments jusqu'à ce que l'on trouve x ou que l'on arrive à la fin de la liste (a = null).

static boolean recherche (int x, Liste a) {

  while (a != null) {
      if (a.contenu == x)
          return true;
      a = a.suivant;
  }
  return false;
}



Figure 3.2 : Suppression d'un élément dans une liste

La fonction recherche peut aussi être écrite de manière récursive

static boolean recherche (int x, Liste a) {

  if (a == null) 
      return false;
  else if (a.contenu == x) 
      return true;       
  else 
      return recherche (x, a.suivant);
}
ou encore

static boolean recherche (int x, Liste a) {

  if (a == null) 
      return false;
  else 
      return (a.contenu == x) || recherche (x, a.suivant);
}
ou encore encore (quoique non recommandé):

static boolean recherche (int x, Liste a) {

  return a != null && (a.contenu == x || recherche (x, a.suivant));
}
Cette écriture récursive est systématique. pour les fonctions sur les listes. En effet, le type des listes vérifie l'équation suivante:

Liste = {Liste_vide}  È   Element × Liste
È est l'union disjointe et × le produit cartésien. Toute procédure ou fonction travaillant sur les listes peut donc s'écrire récursivement sur la structure de sa liste argument. Par exemple, la longueur d'une liste se calcule par

static int longueur(Liste a) {

  if (a == null)
      return 0;
  else
      return 1 + longueur (a.suivant);
}
ce qui est plus élégant que l'écriture itérative qui suit

static int longueurI(Liste a) {

  int   longueur = 0;
  while (a != null) {
      ++longueur;
      a = a.suivant;
  }
  return longueur;
} 
Choisir entre la manière récursive ou itérative est affaire de goût. Autrefois, on disait que l'écriture itérative était plus efficace car utilisant moins de mémoire. Grâce aux techniques nouvelles de compilation (comme l'élimination des récursions terminales), c'est de moins en moins vrai; de plus l'occupation mémoire est, dans les ordinateurs d'aujourd'hui, une question nettement moins critique.

La suppression de la cellule qui contient x s'effectue en modifiant la valeur de suivant contenue dans le prédécesseur de x:  le successeur du prédécesseur de x devient le successeur de x. Un traitement particulier doit être fait si l'élément à supprimer est le premier élément de la liste. La procédure récursive de suppression est très compacte dans sa définition.

static Liste supprimer (int x, Liste a) {

  if (a != null)
      if (a.contenu == x)
          a = a.suivant;
      else 
          a.suivant = supprimer (x, a.suivant);
  return a;      
}
Une procédure itérative demande beaucoup plus d'attention.

static Liste supprimer (int x, Liste a) { 

  if (a != null)
      if (a.contenu == x)
          a = a.suivant;
      else {
          Liste b = a ;
          while (b.suivant != null && b.suivant.contenu != x) 
              b = b.suivant;
          if (b.suivant != null) 
              b.suivant = b.suivant.suivant;
      } 
  return a;       
}
Noter que dans les deux fonctions ci-dessus, on a modifié la liste a, on ne peut donc disposer de deux listes distinctes l'une qui contient x et l'autre identique à la précédente, mais ne contenant pas x. Pour ce faire, il faut recopier une partie de la liste a dans une nouvelle liste, comme le fait le programme suivant, où il y a une utilisation un peu plus importante de la mémoire. Mais l'espace mémoire perdu est récupéré par le glaneur de cellules (GC) de Java, si personne n'utilise l'ancienne liste a. Avec les techniques actuelles de GC à générations, cette récupération s'effectuera très rapidement. Il faut donc noter la différence avec un langage comme Pascal ou C, où on doit se préoccuper de l'espace mémoire perdu, si on veut pouvoir le réutiliser.

static Liste supprimer (int x, Liste a) { 

  if (a != null)
      return null;
  else if (a.contenu == x)
      return a.suivant;
  else
      return new Liste (a.contenu, supprimer (x, a.suivant));
}
Une technique souvent utilisée permet d'éviter quelques tests pour supprimer un élément dans une liste et plus généralement pour simplifier la programmation sur les listes. Elle consiste à utiliser une garde permettant de rendre homogène le traitement de la liste vide et des autres listes. En effet dans la représentation précédente, la liste vide n'a pas la même structure que les autres listes. On utilise une cellule placée au début et n'ayant pas d'information significative dans le champ contenu; l'adresse de la vraie première cellule se trouve dans le champ suivant de cette cellule. On obtient ainsi une liste gardée, l'avantage d'une telle garde est que la liste vide contient au moins la garde, et que par conséquent un certain nombre de programmes, qui devaient faire un cas spécial dans le cas de la liste vide ou du premier élément de liste, deviennent plus simples. Cette notion est un peu l'équivalent des sentinelles pour les tableaux. On utilisera cette technique dans les procédures sur les files un peu plus loin (voir page X) . On peut aussi définir des listes circulaires gardées en mettant l'adresse de cette première cellule dans le champ suivant de la dernière cellule de la liste. Les listes peuvent aussi être gérées par différents autres mécanismes que nous ne donnons pas en détail ici. On peut utiliser, par exemple, des listes doublement chaînées dans lesquelles chaque cellule contient un élément et les adresses à la fois de la cellule qui la précède et de celle qui la suit. Des couples de tableaux peuvent aussi être utilisés, le premier contenu contient les éléments de l'ensemble, le second adrsuivant contient les adresses de l'élément suivant dans le tableau contenu.

Remarque
La procédure ajouter effectue 3 opérations élémentaires. Elle est donc très efficace. En revanche, les procédures recherche et supprimer sont plus longues puisqu'on peut aller jusqu'à parcourir la totalité d'une liste pour retrouver un élément. On peut estimer, si on ne fait aucune hypothèse sur la fréquence respective des recherches, que le nombre d'opérations est en moyenne égal à la moitié du nombre d'éléments de la liste. Ceci est à comparer à la recherche dichotomique qui effectue un nombre logarithmique de comparaisons et à la recherche par hachage qui est souvent bien plus rapide encore.

Exemple
A titre d'exemple d'utilisation des listes, nous considérons la construction d'une liste des nombres premiers inférieurs ou égaux à un entier n donné. Pour construire cette liste, on commence, dans une première phase, par y ajouter tous les entiers de 2 à n en commençant par le plus grand et en terminant par le plus petit. Du fait de l'algorithme d'ajout décrit plus haut, la liste contiendra donc les nombres en ordre croissant. On utilise ensuite la méthode classique du crible d'Eratosthène: on considère successivement les éléments de la liste dans l'ordre croissant et on supprime tous leurs multiples stricts. Ceci se traduit par la procédure suivante :

static Liste listePremier (int n) {

  Liste  a = null;
  int    k;

  for (int i = n; i >= 2; --i) {
       a = ajouter (i, a);
  } 
  k = a.contenu; 
  for (Liste b = a; k * k <= n ; b = b.suivant){ 
      k = b.contenu;   
      for (int j = k; j <= n/k; ++j) 
          a = supprimer (j * k, a);
      }
  return(a); 
}
Remarque
Nous ne prétendons pas que cette programmation soit efficace (loin de là!). Elle est toutefois simple à écrire, une fois que l'on a à sa disposition les fonctions sur les listes. Elle donne de bons résultats pour n inférieur à 10 000. Un bon exercice consiste à en améliorer l'efficacité.

Exemple
Un autre exemple, plus utile, d'application des listes est la gestion des collisions dans le hachage dont il a été question au chapitre 1 (voir page X). Il s'agit pour chaque entier i de l'intervalle [0 ... N-1] de construire une liste chaînée Li formée de toutes les clés x telles que h(x) = i. Les éléments de la liste sont des entiers permettant d'accéder aux tableaux des noms et des numéros de téléphone. Les procédures de recherche et d'insertion de x dans une table deviennent des procédures de recherche et d'ajout dans une liste. Ainsi, si la fonction h est mal choisie, le nombre de collisions devient important, la plus grande des listes devient de taille imposante et le hachage risque de devenir aussi coûteux que la recherche dans une liste chaînée ordinaire. Par contre, si h est bien choisie, la recherche est rapide.

Liste al[] = new Liste[N-1];

static void insertion (String x, int val) {

    int i = h(x);
    al[i] = ajouter (x, al[i]);
}

static int recherche (String x) {

  for (int a = al[h(x)]; a != null; a = a.suivant) {
      if (x.equals(nom[a.contenu]))
          return tel[a.contenu];
  }
  return -1;
}

3.2  Files

Les files sont utilisées en programmation pour gérer des objets qui sont en attente d'un traitement ultérieur, par exemple des processus en attente d'une ressource du système, des sommets d'un graphe, des nombres entiers en cours d'examen de certaines de leur propriétés, etc ... Dans une file les éléments sont systématiquement ajoutés en queue et supprimés en tête, la valeur d'une file est par convention celle de l'élément de tête. En anglais, on parle de stratégie FIFO First In First Out, par opposition à la stratégie LIFO Last In First Out des piles. De façon plus formelle, on se donne un ensemble E, l'ensemble des files dont les éléments sont dans E est noté Fil(E), la file vide (qui ne contient aucun élément) est F0, les opérations sur les files sont vide, ajouter, valeur, supprimer:

Les opérations sur les files satisfont les relations suivantes

Pour F ¹ F0

supprimer(ajouter(x,F)) = ajouter(x ,supprimer(F))
valeur(ajouter(x,F))= valeur(F)


Pour toute file F

estVide(ajouter(x,F)) = faux


Pour la file F0

supprimer(ajouter (x,F0))= F0
valeur(ajouter(x,F0)) = x
estVide(F0) = vrai





Figure 3.3 : File gérée par un tableau circulaire

Une première idée de réalisation sous forme de programmes des opérations sur les files est empruntée à une technique mise en oeuvre dans des lieux où des clients font la queue pour être servis, il s'agit par exemple de certains guichets de réservation dans les gares, de bureaux de certaines administrations, ou des étals de certains supermarchés. Chaque client qui se présente obtient un numéro et les clients sont ensuite appelés par les serveurs du guichet en fonction croissante de leur numéro d'arrivée. Pour gérer ce système deux nombres doivent être connus par les gestionnaires: le numéro obtenu par le dernier client arrivé et le numéro du dernier client servi. On note ces deux nombres par fin et début respectivement et on gère le système de la façon suivante

Dans la suite, on a représenté toutes ces opérations en Java en optimisant la place prise par la file en utilisant la technique suivante: on réattribue le numéro 0 à un nouveau client lorsque l'on atteint un certain seuil pour la valeur de fin. On dit qu'on a un tableau (ou tampon) circulaire.
class File {

  final static int MaxF = 100;

  int         debut;
  int         fin;
  boolean     pleine, vide;
  int         contenu[];    

  File () {
    debut = 0; fin = 0;
    pleine = false; vide = true;
    contenu = new int[MaxF];
  } 

  static File vide () {
    return new File();
  }

  static void faireVide (File f) {
    f.debut = 0; f.fin = 0;
    f.pleine = false; f.vide = true;
  }

  static boolean estVide(File f) {
    return  f.vide;
  }

  static boolean estPleine(File f) {
    return  f.pleine;
  }

  static int valeur (File f) {
    if (f.vide)
        erreur ("File Vide.");
    return  f.contenu[f.debut];
  }

  private static int Successeur(int i) {
    return  (i+1) % MaxF;
  }

  static void ajouter (int x, File f) {
    if (f.pleine)
        erreur ("File Pleine.");
    f.contenu[f.fin] = x;
    f.fin = Successeur(f.fin);
    f.vide = false;
    f.pleine = f.fin == f.debut;
  }

  static void supprimer (File f) {
    if (f.vide)
        erreur ("File Vide.");
    f.debut =  Successeur(f.debut);
    f.vide = f.fin == f.debut;
    f.pleine = false;
  }
}
Une autre façon de gérer des files consiste à utiliser des listes chaînées gardées (voir page X) dans lesquelles on connaît à la fois l'adresse du premier et du dernier élément. Cela donne les opérations suivantes:

class File {
        
    Liste debut;
    Liste fin;

  File (Liste a, Liste b)  {
    debut = a;
    fin = b;
  }
  
  static File vide () {
    Liste garde = new Liste();   
    return new File (garde, garde);
  }

  static void faireVide (File f) {
    Liste garde = new Liste();   
    f.debut = f.fin = garde;
  }

  static boolean estVide (File f) { 
    return f.debut == f.fin;
  }

  static int valeur (File f) {
    Liste b = f.debut.suivant;
    return b.contenu;
  }

  static void ajouter (int x, File f) {
    Liste a = new Liste (x, null);
    f.fin.suivant = a;
    f.fin = a;
  }

  static void supprimer (File f) {   
    if (estVide (f))
        erreur ("File Vide.");
    f.debut = f.debut.suivant;
  }
}



Figure 3.4 : File d'attente implémentée par une liste

Nous avons deux réalisations possibles des files avec des tableaux ou des listes chaînées. L'écriture de programmes consiste à faire de tels choix pour représenter les structures de données. L'ensemble des fonctions sur les files peut être indistinctement un module manipulant des tableaux ou un module manipulant des listes. L'utilisation des files se fait uniquement à travers les fonctions vide, ajouter, valeur, supprimer. C'est donc l'interface des files qui importe dans de plus gros programmes, et non leurs réalisations. Les notions d'interface et de modules seront développées au chapitre 7.

3.3  Piles

La notion de pile intervient couramment en programmation, son rôle principal consiste à implémenter les appels de procédures. Nous n'entrerons pas dans ce sujet, plutôt technique, dans ce chapitre. Nous montrerons le fonctionnement d'une pile à l'aide d'exemples choisis dans l'évaluation d'expressions Lisp.


On peut imaginer une pile comme une boîte dans laquelle on place des objets et de laquelle on les retire dans un ordre inverse de celui dans lequel on les a mis: les objets sont les uns sur les autres dans la boîte et on ne peut accéder qu'à l'objet situé au ``sommet de la pile''. De façon plus formelle, on se donne un ensemble E, l'ensemble des piles dont les éléments sont dans E est noté Pil(E), la pile vide (qui ne contient aucun élément) est P0, les opérations sur les piles sont vide, ajouter, valeur, supprimer comme sur les files. Cette fois, les relations satisfaites sont les suivantes (où P0 dénote la pile vide)

supprimer(ajouter(x,P)) = P
estVide(ajouter(x,P)) = faux
valeur(ajouter(x,P)) = x
estVide(P0) = vrai


A l'aide de ces relations, on peut exprimer toute expression sur les piles faisant intervenir les 4 opérations précédentes à l'aide de la seule opération ajouter en partant de la pile P0. Ainsi l'expression suivante concerne les piles sur l'ensemble des nombres entiers:

 supprimer (ajouter (7 ,
  supprimer (ajouter (valeur (ajouter (5, ajouter (3, P0)))),
    ajouter (9, P0))))


Elle peut se simplifier en:

ajouter (9, P0)


La réalisation des opérations sur les piles peut s'effectuer en utilisant un tableau qui contient les éléments et un indice qui indiquera la position du sommet de la pile. par référence.

class Pile {

  final static int maxP = 100;

  int          hauteur ;
  Element      contenu[];

  Pile ()  {
     hauteur = 0;
     contenu = new Element[maxP];
  }

  static File vide () {
    return new Pile();
  }

  static void faireVide (Pile p) {
    p.hauteur = 0;
  }

  static boolean estVide (Pile p) {
    return p.hauteur == 0;
  }

  static boolean estPleine (Pile p) {
    return p.hauteur == maxP;
  }

  static void ajouter (Element x, Pile p) 
      throws ExceptionPile
  {
    if (estPleine (p))
         throw new ExceptionPile("pleine");
    p.contenu[p.hauteur] = x;
    ++ p.hauteur;
  }

  static Element valeur (Pile p) 
      throws ExceptionPile
  {
    if (estVide (p)) 
        throw new ExceptionPile("vide");
    return p.contenu [p.hauteur - 1];
   }

  static void supprimer (Pile p) 
      throws ExceptionPile
  {
    if (estVide (p)) 
        throw new ExceptionPile ("vide");
    -- p.hauteur;
  }
}
où on suppose qu'une nouvelle classe définit les exceptions ExceptionPile:

class ExceptionPile extends Exception {
  String nom;

  public ExceptionPile (String x) {
      nom = x;
  }
}
Remarques
Chacune des opérations sur les piles demande un très petit nombre d'opérations élémentaires et ce nombre est indépendant du nombre d'éléments contenus dans la pile. On peut gérer aussi une pile avec une liste chaînée, les fonctions correspondantes sont laissées à titre d'exercice. Les piles ont été considérées comme des arguments par référence pour éviter qu'un appel de fonction ne fasse une copie inutile pour passer l'argument par valeur.

3.4  Evaluation des expressions arithmétiques préfixées

Dans cette section, on illustre l'utilisation des piles par un programme d'évaluation d'expressions arithmétiques écrites de façon particulière. Rappelons qu'expression arithmétique signifie dans le cadre de la programmation: expression faisant intervenir des nombres, des variables et des opérations arithmétiques (par exemple: + * / - Ö  ). Dans ce qui suit, pour simplifier, nous nous limiterons aux opérations binaires + et * et aux nombres naturels. La généralisation à des opérations binaires supplémentaires comme la division et la soustraction est particulièrement simple, c'est un peu plus difficile de considérer aussi des opérations agissant sur un seul argument comme la racine carrée, cette généralisation est laissée à titre d'exercice au lecteur. Nous ne considérerons aussi que les entiers naturels en raison de la confusion qu'il pourrait y avoir entre le symbole de la soustraction et le signe moins.

Sur certaines machines à calculer de poche, les calculs s'effectuent en mettant le symbole d'opération après les nombres sur lesquels on effectue l'opération. On a alors une notation dite postfixée. Dans certains langages de programmation, c'est par exemple le cas de Lisp ou de Scheme, on écrit les expressions de façon préfixée c'est-à-dire que le symbole d'opération précède cette fois les deux opérandes, on définit ces expressions récursivement. Les expressions préfixées comprennent:

Une expression préfixée est ou bien un nombre entier naturel ou bien est de l'une des deux formes:

(+ e1 e2)          (* e1 e2)
e1 et e2 sont des expressions préfixées.

Cette définition fait intervenir le nom de l'objet que l'on définit dans sa propre définition mais on peut montrer que cela ne pose pas de problème logique. En effet, on peut comparer cette définition à celle des nombres entiers: ``tout entier naturel est ou bien l'entier 0 ou bien le successeur d'un entier naturel''. On vérifie facilement que les suites de symboles suivantes sont des expressions préfixées.

47
(* 2 3)
(+ 12 8)
(+ (* 2 3) (+ 12 8))
(+ (*  (+ 35 36) (+ 5 6)) (* (+ 7 8) (* 9 9)))
Leur évaluation donne respectivement 47, 6, 20, 26 et 1996.

Pour représenter une expression préfixée en Java, on utilise ici un tableau dont chaque élément représente une entité de l'expression. Ainsi les expressions ci-dessus seront représentées par des tableaux de tailles respectives 1, 5, 5, 13, 29. Les éléments du tableau sont des objets à trois champs, le premier indique la nature de l'entité: (symbole ou nombre), le second champ est rempli par la valeur de l'entité dans le cas où celle ci est un nombre, enfin le dernier champ est un caractère rempli dans les cas où l'entité est un symbole.
class Element {
  boolean       estOperateur; 
  int           valeur;
  char          valsymb;
}
On utilise les fonctions données ci-dessus pour les piles et on y ajoute les procédures suivantes :

static int calculer (char a, int x, int y) {

  switch (a) {
      case '+': return x + y;
      case '*': return x * y;
  }
  return -1;
}
La procédure d'évaluation consiste à empiler les résultats intermédiaires, la pile contiendra des opérateurs et des nombres, mais jamais deux nombres consécutivement. On examine successivement les entités de l'expression si l'entité est un opérateur ou si c'est un nombre et que le sommet de la pile est un opérateur, alors on empile cette entité. En revanche, si c'est un nombre et qu'en sommet de pile il y a aussi un nombre, on fait agir l'opérateur qui précède le sommet de pile sur les deux nombres et on répète l'opération sur le résultat trouvé.


   
   
   
   
   
 + 
  
   
   
   
   
 * 
 + 
  
   
   
   
 + 
 * 
 + 
  
   
   
 35 
 + 
 * 
 + 
  
   
   
   
 71 
 * 
 + 
  
   
   
 + 
 71 
 * 
 + 
  
   
 5 
 + 
 71 
 * 
 + 
  
   
   
   
   
 781 
 + 
  
   
   
   
 * 
 781 
 + 
  
   
   
 + 
 * 
 781 
 + 
  
   
 7 
 + 
 * 
 781 
 + 
  
   
 * 
 15 
 * 
 781 
 + 
  
 9 
 * 
 15 
 * 
 781 
 + 
  


Figure 3.5 : Pile d'évaluation de l'expression dont le résultat est 1996

static void inserer (Element x, Pile p) 
    throws ExceptionPile
{
  Element    y, op;

  while (!(Pile.estVide(p)  || x.estOperateur ||
                          Pile.valeur(p).estOperateur))  {
      y = Pile.valeur(p);
      Pile.supprimer(p);
      op = Pile.valeur(p);
      Pile.supprimer(p);
      x.valeur = calculer (op.valsymb, x.valeur, y.valeur);
  }
  Pile.ajouter(x,p);
}

static int evaluer (Element u[])
    throws ExceptionPile
{
  Pile p = new Pile();

  for (int i = 0; i < u.length ; ++i) {
      inserer (u[i], p);
  }
  return Pile.valeur(p).valeur;
}
Dans ce cas, il peut être utile de donner un exemple de programme principal pilotant ces diverses fonctions. Remarquer qu'on se sert des arguments sur la ligne de commande pour séparer les différents nombres ou opérateurs.

public static void main (String args[]) {

  Element exp[] = new Element [args.length];

  for (int i = 0; i < args.length; ++i) {
     String s = args[i];
     if (s.equals ("+") || s.equals ("*"))
         exp[i] = new Element (true, 0, s.charAt(0));
     else
         exp[i] = new Element (false, Integer.parseInt(s), ' ');
  }    
  try {
      System.out.println (evaluer (exp));
  } catch (ExceptionPile x) {
      System.err.println ("Pile " + x.nom);
  }
}

3.5  Opérations courantes sur les listes

Nous donnons dans ce paragraphe quelques algorithmes de manipulation de listes. Ceux-ci sont utilisés dans les langages où la liste constitue une structure de base. La fonction Tail est une primitive classique, elle supprime le premier élément d'une liste




Figure 3.6 : Queue d'une liste

class Liste {

  Object contenu;
  Liste suivant;

  Liste (Object x, Liste a) {
      contenu = x;
      suivant = a;
  }

  static Liste cons (Object x, Liste a) {
    return new Liste (x, a);
  }

  static Object head (Liste a) {
    if (a == null)
        erreur ("Head d'une liste vide.");
    return a.contenu;
  }

  static Liste tail (Liste a) {
    if (a == null) 
        erreur ("Tail d'une liste vide.");
    return a.suivant;
  }
Des procédures sur les listes construisent une liste à partir de deux autres, il s'agit de mettre deux listes bout à bout pour en construire une dont la longueur est égale à la somme des longueurs des deux autres. Dans la première procédure append, les deux listes ne sont pas modifiées; dans la seconde nConc, la première liste est transformée pour donner le résultat. Toutefois, on remarquera que, si append copie son premier argument, il partage la fin de liste de son résultat avec son deuxième argument.




Figure 3.7 : Concaténation de deux listes par Append

static Liste append (Liste a, Liste b) {
  if (a == null)
      return b;
  else
      return ajouter (a.contenu, append (a.suivant, b)) ;
}

static Liste nConc (Liste a, Liste b) {
  if (a == null) 
      return b;
  else {
      Liste c = a;
      while (c.suivant != null) 
           c = c.suivant; 
      c.suivant = b;
      return a;
  }
}
Cette dernière procédure peut aussi s'écrire récursivement:

static Liste nConc (Liste a, Liste b) {
  if (a == null) 
      return b;
  else {
      a.suivant = nConc (a.suivant, c);
      return a;
  }
}



Figure 3.8 : Concaténation de deux listes par Nconc

La procédure de calcul de l'image miroir d'une liste a consiste à construire une liste dans laquelle les éléments de a sont rencontrés dans l'ordre inverse de ceux de a. La réalisation de cette procédure est un exercice classique de la programmation sur les listes. On en donne ici deux solutions l'une itérative, l'autre récursive, la complexité est en O(n2) donc quadratique, mais classique. A nouveau, nReverse modifie son argument, alors que Reverse ne le modifie pas et construit une nouvelle liste pour son résultat.




Figure 3.9 : Transformation d'une liste au cours de nReverse

static Liste nReverse (Liste a) {
  Liste b = null;
  while (a != null) {
      Liste c = a.suivant;
      a.suivant = b;
      b = a;
      a = c;
  }
 return b;
}

static Liste reverse (Liste a) {
  if (a == null)
      return  a;
  else
      return  append (reverse (a.suivant), 
                      ajouter (a.contenu, null));
} 
On peut aussi avoir une version linéaire de la version récursive, grâce à une fonction auxiliaire accumulant le résultat dans un de ses arguments:
static Liste nReverse (Liste a) {
  return nReverse1 (null, a);
}

static Liste nReverse1 (Liste b, Liste a) {
  if (a == null)
      return b;
  else
      return nReverse1 (ajouter (a.contenu, b), a.suivant);
}
Un autre exercice formateur consiste à gérer des listes dans lesquelles les éléments sont rangés en ordre croissant. La procédure d'ajout devient plus complexe puisqu'on doit retrouver la position de la cellule où il faut ajouter après avoir parcouru une partie de la liste.

Nous ne traiterons cet exercice que dans le cas des listes circulaires gardées, voir page X. Dans une telle liste, la valeur du champ contenu de la première cellule n'a aucune importance. On peut y mettre le nombre d'éléments de la liste si l'on veut. Le champ suivant de la dernière cellule contient lui l'adresse de la première.




Figure 3.10 : Liste circulaire gardée

static Liste inserer (int v, Liste a) {

  Liste b = a;
  while (b.suivant != a && v > head(b.suivant))
      b = b.suivant;
  b.suivant = ajouter (v, b.suivant);        
  a.contenu = head(a) + 1;
  return a;
}   

3.6  Programmes en Caml

/* Déclaration des listes, voir page X */

type element == int;;

type liste = Nil | Cons of cellule

and cellule =
  { mutable contenu : element;
    mutable suivant : liste };;
Remarque: en Caml, les listes sont prédéfinies, et la majorité des fonctions suivantes préexistent. Nous nous amusons donc à les redéfinir. Toutefois, nos nouvelles listes sont modifiables.


(* Liste vide, voir page X *)
let estVide a =
  a = Nil;;

(* Ajouter, voir page X *)
let ajouter x a =
  Cons {contenu = x; suivant = a};;

(* Recherche, voir page X *)
let recherche x a =
 let l = ref a in
 let result = ref false in
 while !l <> Nil do
  match !l with
  | Cons {contenu = y; suivant = s} ->
     if x = y then result := true
     else l := s
  | _ -> ()
 done;
 !result;;

(* Recherche récursive, voir page X *)
let recherche x a =
  match a with
    Nil -> false
  | Cons {contenu = y; suivant = s} ->
     if x = y then true
     else recherche x s;;

let recherche x a =
  match a with
    Nil -> false
  | Cons {contenu = y; suivant = s} ->
     x = y || recherche x s;;

(* Longueur d'une liste, voir page X *)
let rec longueur = function
    Nil -> 0
  | Cons {suivant = reste; _} ->
      1 + longueur reste;;

(* Longueur d'une liste, voir page X *)
let longueur a =
  let r = ref 0
  and accu = ref a in
  while !accu <> Nil do
    incr r;
    match !l with
    | Cons {suivant = reste; _} ->
        accu := reste
    | _ -> ()
  done;
  !r;;

(* Supprimer, voir page X *)
let rec supprimer x a =
   match a with
   Nil -> a
 | Cons ({contenu = c; suivant = s} as cellule) ->
    if c = x then s
    else begin 
      cellule.suivant <- supprimer x s;
      a
    end;;

let rec supprimer x a =
   match a with
   Nil -> Nil
 | Cons {contenu = c; suivant = s} ->
    if c = x then s
    else Cons {contenu = c; suivant = s};;

(* Liste des nombres premiers, voir page X *)
let liste_premier n =
  let a = ref Nil in
  for i = n downto 2 do ajouter i a done;
  let k = ref 2 in
  let b = ref !a in
  while !k * !k <= n do
    match !b with
      Nil -> failwith "liste_premier"
    | Cons {contenu = c; suivant = s} ->
       k := c;
       for j = c to n / c do
         supprimer (j * !k) a done;
       b := s
  done;
  a;;

(* Les files avec des vecteurs, voir page X *)
let maxF = 100;;

type 'a file =
  { mutable debut: int;
    mutable fin: int;
    mutable pleine: bool;
    mutable vide: bool;
    contenu: 'a vect };;

let vide () = {debut = 0; fin = 0;
  pleine = false; vide = true; 
  contenu = make_vect maxF 0};;

let faireVide f = begin
  f.debut = 0; f.fin = 0;
  f.pleine = false; f.vide = true; 
  end;;

let estVide f = f.vide;;

let estPleine f = f.pleine;;

let valeur f = begin
  if f.vide then failwith "Pile vide.";
  f.contenu.(f.debut)
  end;;

let successeur x = (x + 1) mod maxF ;;

let ajouter x f = begin
  if f.pleine then failwith "Pile pleine.";
  f.contenu.(f.fin) <- x;
  f.fin <- successeur (f.fin);
  f.vide <- false;
  f.pleine <- f.fin = f.debut;
  end;;

let supprimer f = begin
  if f.vide then failwith "File vide.";
  f.debut <- successeur (f.debut);
  f.vide <-  f.fin = f.debut;
  f.pleine <- false;
  end;;

(* Les files avec des listes, voir page X *)

type 'a liste = Nil | Cons of 'a cellule
and 'a cellule =
  {contenu: 'a; mutable suivant: 'a liste};;

type 'a file =
  {mutable debut: 'a cellule;
   mutable fin: 'a cellule };;

let vide () = 
  let b = {contenu = 0; suivant = Nil} in
  {debut = b; fin = b};;

let faireVide f =
  let b = {contenu = 0; suivant = Nil} in
  f.debut <- b; f.fin <- b;;

let estVide f = f.fin == f.debut;;

let valeur f =
  match f.debut.suivant with
    Nil -> failwith "Pile vide"
  | Cons cell -> cell.contenu;;

let ajouter x f =
  let b = {contenu = x; suivant = Nil} in
  f.fin.suivant <- Cons b;
  f.fin <- b;;

let supprimer f =
  match f.debut.suivant with
    Nil -> ()
  | Cons cell -> f.debut <- cell;;

(* Déclarations et opérations sur les piles,
 voir page X *)

exception ExceptionPile of string;;

let maxP = 100;;

type 'a pile =
  {mutable hauteur: int;
   mutable contenu: 'a vect};;

let vide () = 
  {hauteur = 0; contenu = make_vect maxP 0};;

let faireVide p = p.hauteur <- 0;;

let estVide p = p.hauteur = 0;;

let estPleine p = p.hauteur = maxP;;

let ajouter x p =
  if estPleine p then
    raise (ExceptionPile "pleine");
  p.contenu.(p.hauteur) <- x;
  p.hauteur <- p.hauteur + 1;;

let pvaleur p =
  if estVide p then
    raise (ExceptionPile "vide");
  p.contenu.(p.hauteur - 1);;

let psupprimer p =
  if estVide p then
    raise (ExceptionPile "vide");
  p.hauteur <- p.hauteur - 1;;


(* Opérations sur les piles, version plus traditionelle *)

type 'a pile == 'a list ref;;

let vide () = ref [];;

let faireVide (p) = p := [];;

let estVide p = (!p = []);;

let ajouter x p = p := x :: !p;;

let valeur p = match !p with
    [] -> failwith "Pile Vide."
  | x :: p' -> x;;

let supprimer p =  p := match !p with
    [] -> failwith "Pile Vide."
  | x :: p' -> p';;

(* Evaluation des expressions préfixées,
   voir page X *)
type expression == element vect
and element =
    Symbole of char
  | Nombre of int;;

let calculer a x y =
  match a with
    `+` -> x + y
  | `*` -> x * y
  | _ -> failwith "unknown operator";;

let rec inserer x p =
  match x with
    Symbole c -> ajouter x p
  | Nombre n ->
     if pvide p then ajouter x p else
     match valeur p with
       Symbole c as y -> ajouter x p
     | Nombre m ->
        supprimer p;
        match valeur p with
          Symbole c ->
          supprimer p;
          let res = Nombre (calculer c n m) in
          inserer res p
        | _ -> failwith "pile mal construite";;

let evaluer u =
  let p =
    {hauteur = 0;
     contenu = make_vect 100 (Nombre 0)} in
  for i = 0 to vect_length u - 1 do
    inserer u.(i) p
  done;
  match valeur p with
    Symbole c -> failwith "pile mal construite"
  | Nombre n -> n;;

let pile_of_string s =
  let u = make_vect (string_length s) (Symbole ` `) in
  let l = ref 0 in
  for i = 0 to string_length s - 1 do
    let element =
      match s.[i] with
        `0` .. `9` as c ->
         let n =
          int_of_char c - int_of_char `0` in
         begin match u.(!l) with
           Symbole c -> Nombre n
         | Nombre m ->
            decr l; Nombre (10 * m + n)
         end
      | c -> Symbole c in
    incr l;
    u.(!l) <- element
  done;
  sub_vect u 0 !l;;

evaluer
 (pile_of_string
   "(* (+ 10 (* 2 3)) (+ (* 10 10) (* 9 9)))");;
- : int = 1996

(* Tail et cons, voir page X *)
let tail a = 
  match a with
    Nil -> failwith "tail"
  | Cons cell -> cell.contenu;;

let cons x l =
  Cons {contenu = x; suivant = l};;

(* Append et nconc, voir page X *)
let rec append a b =
  match a with
    Nil -> b
  | Cons cell ->
     cons (cell.contenu)
          (append cell.suivant b);;

let nconc ap b =
  match !ap with
    Nil -> ap := b
  | _ ->
     let c = ref !ap in
     while
      match !c with
        Cons {suivant = (Cons cell as l); _} ->
          c := l; true
      | _ -> false
     do () done;
     match  !c with
        Cons cell -> cell.suivant <- b
      | _ -> ();;

(* Version plus conforme au style Caml:
 avec une fonction récursive locale,
 on fait l'opération physique sur la liste *)
let rec nconc a b =
  let rec nconc1 c =
   match c with
     Nil -> b
   | Cons ({suivant = Nil; _} as cell) ->
       cell.suivant <- b; a
   | Cons {suivant = l; _} -> nconc1 l in
 nconc_aux a;;

(* Nreverse et reverse,
 voir page X *)
let nreverse ap =
 let a = ref !ap in
 let b = ref Nil in
 let c = ref Nil in
 while
   match !a with
   | Nil -> false
   | Cons ({suivant = s; _} as cell) ->
      c := s;
      cell.suivant <- !b;
      b := !a;
      a := !c;
      true
 do () done;
 ap := !b;;

(* Version plus conforme à Caml:
 on renverse la liste en place
 et l'on rend la nouvelle tête de liste *)
let nreverse l =
 let rec nreverse1 a b =
  match a with
  | Nil -> b
  | Cons ({suivant = s; _} as cell) ->
     cell.suivant <- b;
     nreverse1 s a in
  nreverse_aux l Nil;;

let rec reverse a =
   match a with
   | Nil -> a
   | Cons cell ->
      append (reverse cell.suivant)
             (cons (cell.contenu) Nil);;

(* Insert, voir page X *)
let insert v l =
  match l with
    Nil -> failwith "insert"
  | Cons c ->
     let rec insert_aux a =
      match a with
      | Nil -> failwith "insert"
      | Cons cell ->
         if v > cell.contenu && cell != c
         then insert_aux cell.suivant
         else cell.suivant <-
                cons v cell.suivant in
     insert_aux c.suivant;
     c.contenu <- l.contenu + 1;;

Previous Next Contents