Les implémentations des types abstraits Pile et File mises en place dans les 2 fiches précédentes s'appuient sur des listes chaînées (circulaire, ou non) dont les maillons sont alloués dynamiquement.

Dans certains cas où le coût pratique des algorithmes est un critère absolument essentiel, il peut être intéressant d'avoir recours à un autre type d implémentation des piles et des files, qui s'appuie sur un tableau.

Cette fiche introduit ces implémentations "basées tableau" des piles et des files.

  • Pour les piles, vous verrez, c'est très simple.
  • Pour les files, on s'appuie sur une structure de données un peu plus rusée, nommé Buffer Circulaire (Ring Buffer).

1. Introduction

Les implémentations des types abstraits Pile et File mises en place dans les 2 fiches précédentes s'appuient sur des listes chaînées dynamiques (circulaire, ou non). Ces implémentation "de référence" on ceci d'intéressant qu'elles ne limitent pas le nombre d'élément pouvant être placé dans la pile (resp. la file).

Par contre, elles recourent à l'allocation dynamique. Or, la réservation de la mémoire avec calloc() pour chaque maillon et la segmentation de la mémoire qui en résulte implique, en pratique, un coût algorithmique à chaque enfilage/empilage qui, dans certains contextes où il faut aller très vite, s'avèrent pénalisant.

Lorsque :

  1. le nombre maximal d'éléments qui sera contenu dans la pile (resp. la file) est connu à l'avance :
  2. et qu'on a besoin d'un code extrêmement efficace

il peut s'avérer très intéressant d'implanter la pile (ou la file) au moyen d'un tableau.

Le fait d'utiliser un tableau alloué une fois pour toute pour stocker les éléments :

  • évite toute allocation dynamique et libération de mémoire lors de l'ajout ou la suppresssion d'un élément
  • et évite la segmentation mémoire, puisque tous les éléments soient concentrés dans le tableau, en une seule zone mémoire.

Le nombre maximal d'élément que pourra contenir la pile, ou la file, est alors le nombre de cases du tableau.

Exemples de cas où l'implémentation basée tableau est intéressante pour les Piles :

  • Lorsqu'on exécute un programme la "pile d'appel de fonction" est implantée au moyen d'un tableau. D'ailleurs, c'est pour cela qu'il existe une limite, fixée par le système d'exploitation, au nombre d'appels de fonction pouvant être empilés, par exemple lors d'un appel récursif.

Exemples de cas où l'implémentation basée tableau est intéressante pour les Files :

  • En traitement du signal, lorsque par exemple des échantillons doivent être stockés entre un algorithme qui les produit et un algorithme qui les consomme.
  • Dans certaines communications entre processus, ou entre processeurs. Par exemple, dans la communication entre la lecture d'échantillons audio dans un fichier audio par le processeur central, et l'utilisation des échantillons sur la carte son de la machine, via le bus PCI.

2. Implémentation du TAD Pile s'appuyant sur un tableau

Dans cette implémentation, le TAD Pile est réalisé au moyen d'un tableau, donc de taille fixée, dont seulement le début est utilisé.

  • La tête de pile est la dernière case utilisée dans le tableau.
  • Empiler un élément, c'est ajouter cet élément dans la première des cases qui n'est pas encore utilisée.
  • Dépiler un élément, c'est retourner l'élément qui était dans la dernière case et diminuer de un le nombre de cases utilisées.

En langage C, le type Pile, que nous nommerons lifotab_t, est alors défini par :

  • Un tableau, alloué dynamiquement
  • La taille du tableau (nombre maximum d'éléments que peut accueillir la pile)
  • Le nombre d'éléments présents dans la pile (nombre de cases utilisées au début du tableau)

Voici, par l'image, comment s'utiliserait le tableau :

Implémentation du TAD pile par un tableau.
Dans cet exemple, les éléments sont de type double et la pile peut contenir au maximum 7 éléments.

On donne ci-dessous ce que pourrait être le fichier lifotab.h pour cette implémentation. Notez :

  • La déclaration du type lifotab_t
  • Le fait que, dans cette API, pour les fonction d'empilage int lifotab_push(element_t e, lifotab_t * p_stack), et de défilage si la pile est pleine et que l'empilage n'est pas possible, on préfère retourner un code erreur que déclarer des préconditions.


        // fichier lifotab.h pour l'implantation du TAD pile au moyen d'un tableau
        #ifndef lifotab_TAB_H_
        #define lifotab_TAB_H_

        #include "element.h"  // Comme introduit dans l'implémentation de référence des listes
                              // on suppose qu'il existe un fichier définissant
                              // le type des éléments placés dans la pile

        // 1. declaration du type lifotab_t
        typedef struct {
          element_t * tab;  // pointeur sur tableau d'élément alloué dynamiquement
          int tab_size;     // taille du tableau
          int nb_elts;      // nombre d'éléments dans la pile (nombre de cases utilisées dans le tableau)
        } lifotab_t;

        // 2. prototype des fonctions de l'API du TAD Pile

        // Crée et retourne un pile vide pouvant accueillir au maximum  nb_max  éléments
        // ou une pile vide en cas d'erreur
        lifotab_t lifotab_new(int nb_max);

        // Détruit la pile et retourne une pile vide
        lifotab_t lifotab_delete(lifotab_t pile);

        // Retourne 1 si la pile  stack   est vide, 0 sinon
        int lifotab_is_empty(lifotab_t stack);

        // Retourne le nombre d'éléments présents dans la pile
        int lifotab_count(lifotab_t stack);

        // Ajoute l'élément e à la pile pointée par  p_stack
        // Retourne 1 si l'ajout a pu être fait ; 0 sinon (nombre max d'éléments atteint)
        int lifotab_push(element_t e, lifotab_t * p_stack);

        // Retourne l'élément en tête de pile (sans l'enlever de la pile)
        // PRECONDITION : la pile  stack  ne doit pas être vide
        element_t lifotab_peek(lifotab_t stack);

        // Enlève l'élément en tête de la pile, et stocke cet élément dans   *p_tete
        // Retourne 1 si la suppression a pu être faite ; 0 sinon (pile vide)
        int lifotab_pop(lifotab_t * p_stack, element_t *p_tete);

        #endif
        

Et voici un extrait du fichier lifotab.c, avec les fonctions lifotab_new(), lifotab_push(), et lifotab_pop().


        // Extrait du fichier lifotab.c pour l'implantation du TAD pile au moyen d'un tableau
        #include <assert.h>
        #include "lifotab.h"

        // Retourne un pile vide pouvant accueillir au maximum  nb_max  éléments
        lifotab_t lifotab_new(int nb_max) {
          lifotab_t stack;
          stack.tab = NULL;
          stack.tab_size = 0;
          stack.nb_elt = 0;
          stack.tab = calloc(nb_max, sizeof(element_t));
          i(stack.tab == NULL) {
            return stack ; // en cas d'echec d'allocation, on retourne une pile vide
          }
          stack.tab_size = nb_max;
          return stack;
        }

        // Retourne 1 si la pile  s   est vide, 0 sinon
        int lifotab_is_empty(lifotab_t stack) {
          return (stack.nb_elt == 0) ;
        }

        // Ajoute l'élément e à la pile pointée par p_stack
        // Retourne 1 si l'ajout a pu être fait ; 0 sinon (nombre max d'éléments atteint)
        int lifotab_push(element_t e, lifotab_t * p_stack) {
          // gestion du cas "pile pleine"
          if( p_stack->nb_elt == p_stack->tab_size) {
            return 0;
          }
          // on place l'élément dans la première case vide du tableau
          p_stack->tab[ p_stack->nb_elt ] = e;
          p_stack->nb_elt ++;
          return 1;
        }

        ... ...

        // Enlève l'élément en tête de la pile, et stocke cet élément dans   *p_tete
        // Retourne 1 si la suppression a pu être faite ; 0 sinon (pile vide)
        int lifotab_pop(lifotab_t * p_stack, element_t *p_tete) {
          // gestion du cas "pile vide"
          if( lifotab_is_empty(*p->stack) ) {
            return 0;
          }
          *p_tete = p_stack->tab[p_stack->nb_elt -1];
          p_stack->nb_elt --;   // on indique que la dernière case qui était occupée devient libre
          return 1;
        }

        ... ...

        

Dans cette implémentation à base de tableau, toutes les fonctions sont bien à coût constant, O(1).

Comme il n'y a pas d'allocation dynamique de mémoire à faire à l'empilage ou au dépilage, la complexité pratique (mesurable) des fonctions est particulièrement bonne.

Par contre, cette implémentation souffre de 2 problèmes :

  1. Le nombre maximal d'éléments que peut contenir la pile est déterminé une fois pour toutes. Si ce nombre maximal n'est pas connu à la création de la pile, cette implémentation n'est pas appropriée.
  2. La pile occupe beaucoup de mémoire dès sa création (la taille du tableau). C'est bien dommage si, durant son utilisation, le nombre d'éléments qu'elle contient s'avère en fait être souvent très inférieur au nombre maximal d'éléments.

3. Buffer circulaire (Ring Buffer) : implémentation du TAD File s'appuyant sur un tableau

Pour implanter une File au moyen d'un tableau, on étend le principe utilisé pour les piles. Dans un premier temps, les éléments sont enfilés à la fin de la zone occupée dans le tableau et on défilés au début de la zone occupée.

Au fur et à mesure des opérations d'enfilage et de défilage, il arrive un moment ou la fin du tableau est pleine, mais le début est vide. Si on enfile un nouvel élément, il sera alors ajouté dans la première case du tableau, comme le montre l'image suivante :

Implémentation du TAD file par un tableau ("buffer circulaire").
Dans cet exemple, les éléments sont de type double et la file peut contenir au maximum 7 éléments.

Autrement dit, le tableau est utilisé "circulairement", modulo sa taille.

De ce fait, de façon théorique, et même si cela n'est pas une représentation bien propre de la mémoire, un buffer circulaire est souvent représenté graphiquement au moyen d'un cercle :

Représentation théorique d'un buffer circulaire au moyen d'un cercle.


               

Le buffer ici représenté peut contenir au maximum 12 éléments (ici des double).

Etat après ajout (enfilage) de 4 éléments.
Etat après ajout (enfilage) d'un élément.
Etat après suppression (défilage) d'un élément.
Etat après suppression (défilage) de 2 éléments.
Etat après ajout (enfilage) de 8 éléments.
Etat après suppression (défilage) de 5 éléments.

En pratique, en C, un buffer circulaire (ring buffer en anglais) est un tableau (de taille fixée à la création) dans lequel on utilise seulement nb éléments, à partir de la case de tête et modulo la taille du tableau.

Le type C peut être déclaré ainsi :


         // extrait du fichier ringbuffer.h pour l'implantation du TAD file au moyen d'un buffer circulaire
         typedef struct {
           element_t     *buffer; // tableau qui sera alloué dynamiquement
           int size;              // taille du tableau (nb max d'elements)
           int id_head;           // indice de la case du tableau contenant la tête
           int nb;                // nombre d'éléments utilisés dans le buffer
         } ringbuffer_t ;
         
  • Ajouter un élément, si il reste de la place, c'est placer cet élément dans la première case libre après de la case de queue.
    Si la queue est la dernière case du tableau, on recommence au début.
    Autrement dit, le nouvel élément est ajouté dans la case d'indice (id_head + nb) % size ;
  • Enlever un élément, si il y en a au moins un, c'est diminuer nb de 1 et incrémenter id_head de 1.
    Si id_head atteint le nombre de case du tableau, alors la nouvelle tête est la première case du tableau.
    Autrement dit, la nouvelle valeur de id_head est calculée par : (id_head + 1) % size ;

La "circularité" est ainsi réalisée en manipulant le tableau modulo sa taille.

Comme le nombre maximal d'éléments est limité par la taille du tableau, on préfère que les fonction d'enfilage et de défilage retournent un code erreur si elles échouent (cas "plus de place" pour enfilage ; et "ring buffer vide" pour défilage).

L'API du module ringbuffer peut être alors :


         // fichier ringbuffer.h pour l'implantation du TAD file au moyen d'un buffer circulaire
         #ifndef ringbuffer_H_
         #define ringbuffer_H_

         #include 

         #include "element.h" // on suppse que le type element_t est déclaré dans ce fichier

         // 1. declaration explicite du type ringbuffer_t.
         typedef struct {
           element_t     *buffer; // tableau qui sera alloué dynamiquement
           int size;
           int id_head;  // indice de la case du tableau contenant la tête
           int nb;       // nombre d'éléments utilisés dans le buffer
         } ringbuffer_t ;

         // 2. prototype des fonctions de l'API du TAD Pile

         // Crée et retourne un pile vide pouvant accueillir au maximum  nb_max  éléments
         // ou une pile vide en cas d'erreur
         ringbuffer_t ringbuffer_new(int nb_max);

         // Détruit la pile et retourne une pile vide
         ringbuffer_t ringbuffer_delete(ringbuffer_t rb);

         // Retourne 1 si le buffer est vide, 0 sinon
         int ringbuffer_is_empty(ringbuffer_t rb);

         // Retourne le nombre d'éléments présents dans la pile
         int ringbuffer_count(ringbuffer_t rb);

         // Ajoute l'élément e au buffer pointée par p_rb
         // Retourne 0 si l'ajout a pu être fait ; une valeur non nulle sinon (nombre max d'éléments atteint)
         int ringbuffer_enqueue(element_t e, ringbuffer_t * p_rb);

         // Retourne l'élément en tête dans dans *p_e (sans l'enlever de la pile)
         // PRECONDITION :  ring buffer non vide
         element_t ringbuffer_peek(ringbuffer_t rb);

         // Retourne l'élément en tête du buffer *p_rb dans dans *p_e
         // et enlève cet élément du buffer.
         // Retourne 0 si OK ; une valeur non nulle en cas d'erreur (cas ring buffer vide)
         int ringbuffer_dequeue(element_t *p_e, ringbuffer_t * p_rb);
         #endif
       

On donne ci-après le code de du fichier ringbuffer.c :


       // fichier ringbuffer.c pour l'implantation du TAD file au moyen d'un buffer circulaire
       #include "ringbuffer.h"
       // Crée et retourne un pile vide pouvant accueillir au maximum  nb_max  éléments
       // ou un ringbuffer vide en cas d'erreur
       ringbuffer_t ringbuffer_new(int nb_max) {
         ringbuffer_t rb;
         rb.buffer   = NULL;
         rb.size     = 0;
         rb.id_head  = 0;
         rb.nb       = 0;
         rb.buffer = calloc(nb_max, sizeof(element_t));
         if(rb.buffer == NULL) {
           return rb;
         }
         rb.size     = nb_max;
         return rb;
       }

       // Détruit la pile et retourne une pile vide
       ringbuffer_t ringbuffer_delete(ringbuffer_t rb) {
         free(rb.buffer);
         rb.buffer   = NULL;
         rb.size     = 0;
         rb.id_head  = 0;
         rb.nb       = 0;
         return rb;
       }

       // Retourne 1 si le buffer est vide, 0 sinon
       int ringbuffer_is_empty(ringbuffer_t rb) {
         return rb.nb == 0;
       }

       // Retourne le nombre d'éléments présents dans la pile
       int ringbuffer_count(ringbuffer_t rb) {
         return rb.nb;
       }

       // Ajoute l'élément e au buffer pointée par p_rb
       // Retourne 0 si l'ajout a pu être fait ; une valeur non nulle sinon (nombre max d'éléments atteint)
       int ringbuffer_enqueue(element_t e, ringbuffer_t * p_rb){
         if(p_rb->nb == p_rb->size) {
           return 1;
         }
         // calcul de l'indice de la case où ajouter
         int id_tail = (p_rb->id_head + p_rb->nb) % p_rb->size ; // notez le modulo !
         p_rb->buffer[id_tail] = e;
         p_rb->nb ++;
         return 0;
       }

       // Retourne l'élément en tête dans dans *p_e (sans l'enlever de la pile)
       // PRECONDITION :  ring buffer non vide
       element_t ringbuffer_peek(ringbuffer_t rb) {
         assert( ! ringbuffer_is_empty(rb)) ; // vérification de la précondition
         return rb.buffer[rb.id_head];
       }

       // Retourne l'élément en tête du buffer *p_rb dans dans *p_e
       // et enlève cet élément du buffer.
       // Retourne 0 si OK ; une valeur non nulle en cas d'erreur (cas ring buffer vide)
       int ringbuffer_dequeue(element_t *p_e, ringbuffer_t * p_rb){
         // on récupère la tête
         if(ringbuffer_peek(p_e, *p_rb) != 0){
           return 1;
         }
         // on libère la case en tête
         p_rb->nb -- ;
         p_rb->id_head = (p_rb->id_head + 1) % p_rb->size ; // notez le modulo !
         return 0;
       }
       

Et voici enfin un code de test (on suppse que le type element_t est déclaré comme double dans le fichier element.h) :


         // fichier ringbuffer_test.c
         #include 
         #include "ringbuffer.h"

         int main() {
           element_t e;

           ringbuffer_t rb = ringbuffer_new(10);
           ringbuffer_enqueue(1.0, &rb);

           ringbuffer_peek(&e, rb);
           printf("TETE %lf\n", e);

           for(int i = 2 ; i<= 10 ; i ++) {
             printf("ajout de %lf\n", (double)i);
             ringbuffer_enqueue(i, &rb);
           }

           printf("Count (doit valoir 10) %d\n", ringbuffer_count(rb));

           ringbuffer_peek(&e, rb);
           printf("TETE %lf\n", e);

           printf("Ajout invalide ? %d \n", ringbuffer_enqueue(11.0, &rb));
           printf("Count (doit valoir 10) %d\n", ringbuffer_count(rb));

           ringbuffer_peek(&e, rb);
           printf("TETE %lf\n", e);

           for(int i = 0 ; i< 8 ; i ++) {
             ringbuffer_dequeue(&e, &rb);
             printf("POP %d =>  %lf\n", i, e);
           }
           printf("Count (doit valoir 2) %d\n", ringbuffer_count(rb));

           ringbuffer_enqueue(11.0, &rb);
           ringbuffer_enqueue(12.0, &rb);

           printf("Count (doit valoir 4) %d\n", ringbuffer_count(rb));

           for(int i = 0 ; i< 4 ; i ++) {
             ringbuffer_dequeue(&e, &rb);
             printf("POP %d =>  %lf\n", i, e);
           }

           printf("Count (doit valoir 0) %d\n", ringbuffer_count(rb));
           printf("Suppression invalide ? %d \n", ringbuffer_dequeue(&e, &rb));

           ringbuffer_enqueue(13.0, &rb);
           ringbuffer_dequeue(&e, &rb);
           printf("POP %d =>  %lf\n", 13, e);
           printf("Count (doit valoir 0) %d\n", ringbuffer_count(rb));
           rb = ringbuffer_delete(rb);
         }
       

Bien sûr implémentation des Files par buffer circulaire a les mêmes avantages et inconvénients que l'implémentation des Piles à base de tableau :

  • Elle est très efficace ;
  • mais le nombre d'éléments est limité par la taille du tableau et toute la mémoire est allouée dès la création du ring buffer.