précédent suivant haut Contents Index

LES LISTES


fonctions de base et manipulations (base_lst)

Les listes sont un regroupement ordonné de données effectué de manière à ce que chaque composante sache où se trouve la suivante. En C, une composante sera une structure contenant la valeur mémorisée, mais également un pointeur sur la composante suivante. Sur fichier ou dans les langages ne possédant pas de pointeurs, on utilisera la méthode du super-tableau, le pointeur étant remplacé par l'indice, dans le super-tableau, de la composante suivante. Une liste est accessible par l'adresse de sa première composante. On supposera dans la suite que les valeurs à mémoriser sont de type flottant, mais évidement tout autre type de données est possible (même tableaux, structures, listes...). La déclaration en C sera donc :
typedef float type_val;
typedef struct scomp {type_val val; struct scomp *suiv;} type_comp;
typedef type_comp *adr_comp;
Nous représenterons les composantes des listes (de type type_comp) ainsi :

élément de liste

Supposons disposer de la liste ci-dessous en mémoire (nous verrons plus loin comment la créer en mémoire). La variable prem, de type adr_comp (donc pointeur sur une composante) contient l'adresse de la première composante. Le champ suiv de la dernière composante contient la valeur NULL, donc ne pointe sur rien.

liste*

Pour simplifier les algorithmes, on peut mettre d'office une composante particulière en début et fin de liste (appelée sentinelle). Ceci évite de devoir traiter de manière particulière le premier et dernier élément de la liste, puisque chaque composante utile possède toujours un précédent et un suivant. Cette méthode prend un peu plus de mémoire (négligeable en % pour de longues listes) et évite des tests systématiques dans le boucles, alors qu'ils ne servent que pour les extrémités (d'où gain de temps appréciable, toujours en cas de longues listes). Une autre méthode pour repérer le dernier élément est de le faire pointer sur lui-même. Nous utiliserons dans la suite des listes sans sentinelle, le dernier élément pointant sur NULL.

L'appel de : affiche_lst(prem) affichera à l'écran le contenu de la liste :

void affiche_lst(adr_comp l)
 {
  while(l!=NULL)
   {
    printf("%6.1f ",l->val);
    l=l->suiv;
   }
  printf("\n");
 }
l pointe sur la première composante. On affiche la valeur pointée par l puis on fait pointer l sur son suivant. On répète le processus jusqu'en fin de liste (lorsque l'on pointe sur NULL). La variable l doit être locale (donc passée par valeur) pour ne pas modifier le contenu de prem, et donc perdre l'adresse du début de la liste, ce qui empêcherait tout accès ultérieur à la liste.

Comme pour les chaînes de caractères, plutôt que de gérer un variable entière indiquant toujours la longueur actuelle de la liste, c'est la spécificité du dernier élément (ici, le champ suiv contient NULL) qui permet de préciser que l'on est en fin de liste. Pour connaître la longueur d'une liste, on utilisera :

int longueur_lst(adr_comp l)
 {
  int n=0;
  while(l!=NULL)
   {
    l=l->suiv;
    n++;
   }
  return(n);
 }
Les listes ont l'avantage d'être réellement dynamiques, c'est à dire que l'on peut à loisir les rallonger ou les raccourcir, avec pour seule limite la mémoire disponible (ou la taille du super-tableau). Par exemple pour insérer une nouvelle composante en début de liste, on utilisera :
void insert_premier(adr_comp *prem,type_val val)
 {
  adr_comp nouv;
  nouv=(adr_comp)malloc(sizeof(type_comp));
  nouv->val=val;
  nouv->suiv=*prem;
  *prem=nouv;
 }
En insérant la valeur 5 à notre exemple précédent, on obtient la liste :

inséré

La variable prem a dû être passée par adresse, pour que l'on récupère bien l'adresse de la nouvelle première composante (appel : insert_premier(&prem,5)). En fait le schéma ci-dessus n'est qu'une représentation abstraite de la liste (chaque composante pointe sur la suivante), alors que les composantes sont physiquement placées différemment en mémoire. Dans le meilleur des cas, la nouvelle composante créée a été placée juste derrière celles déjà existantes (premier emplacement libre). Un schéma plus proche de la réalité serait donc :

ordonner

Ces deux schémas sont équivalents (les liens partent des mêmes cases, et pointent sur les mêmes cases), seule la disposition des cases diffère. En fait ceci nous montre bien que la disposition réelle en mémoire ne nous intéresse pas (jamais la valeur effective de l'adresse contenue dans un pointeur ne nous intéressera). Dans nos schémas, nous choisirons donc une disposition des composantes correspondant plus au problème traité qu'à la disposition réelle en mémoire (par exemple dans le schéma suivant, on utilise une représentation bidimensionnelle, alors que la mémoire n'est qu'unidimensionnelle).

Le second avantage des listes est que l'insertion d'une composante au milieu d'une liste ne nécessite que la modification des liens avec l'élément précédent et le suivant, le temps nécessaire sera donc indépendant de la longueur de la liste. Supposons disposer d'une variable X de type adr_comp, pointant sur la composante de valeur 10. L'appel de insert_après(X,15) donnera le résultat suivant :
insertion

void insert_après(adr_comp prec,type_val val)
 {
  adr_comp nouv;
  nouv=(adr_comp)malloc(sizeof(type_comp));
  nouv->val=val;
  nouv->suiv=prec->suiv;
  prec->suiv=nouv;
 }
Cette fonction permet l'insertion en tout endroit de la liste, sauf en première position (qui n'a pas de précédent dans notre cas puisque sans sentinelle), il faut traiter spécifiquement le premier de la liste (voir plus haut). L'insertion nécessite la connaissance de l'adresse du précédent et du suivant. Les composantes ne comportant que des liens vers l'élément suivant, il faut nécessairement donner à la fonction insert_après l'adresse du précédent, alors que d'habitude on préfère donner l'élément devant lequel on veut faire l'insertion (tableaux par exemple). Ceci nous montre le principal problème des listes : l'accès est séquentiel. On devra, pour chercher la composante précédente, parcourir toute la liste depuis son début (on donne l'adresse du début de la liste et de l'élément dont on cherche le précédent):
adr_comp rech_prec(adr_comp prem, adr_comp suiv)
/* rend l'adresse, NULL si non trouvé */
 {
  while(prem->suiv!=suiv && prem!=NULL)
     prem=prem->suiv;
  return(prem);
 }
Une autre solution, si l'on a fréquemment besoin de parcourir la liste vers l'avant et vers l'arrière, est de stocker dans chaque composante l'adresse du suivant et celle du précédent. Ceci nécessite plus de place en mémoire et ralentit un peu les manipulations de base (il y a plus de liens à mettre à jour). Mais même dans ce cas, l'accès reste séquentiel. Ceci empêche d'utiliser un certain nombre d'algorithmes utilisant l'accès direct (la dichotomie par exemple). Pour trouver l'adresse du Nième élément d'une liste il faudra utiliser (on suppose numéroter 0 le premier) :
adr_comp rech_ind(adr_comp l, int i)
/* rend l'adresse du (i+1)ième, NULL si liste trop courte */
 {
  int j;
  for(j=0;j<i && l!=NULL;j++) l=l->suiv;
  return(l);
 }
La recherche d'un élément, même si la liste est triée, se fera donc toujours séquentiellement :
adr_comp rech_val(adr_comp l, type_val v)
/* rend l'adresse, NULL si non trouvé */
 {
  while(l!=NULL && l->val!=v) l=l->suiv;
  return(l);
 }
Pour créer une liste, la solution la plus simple consiste à boucler sur un appel de la fonction insert_premier, mais les éléments seront stockés dans l'ordre inverse de leur introduction (le dernier saisi sera placé en premier). Pour une création de liste dans l'ordre, on fera :
adr_comp saisie_lst(void)
 {
  adr_comp prem=NULL,prec,actu; /* premier, précédent, actuel*/
  type_val v;
  int err;
  do
   {
    printf("entrez votre prochaine valeur, un caractère pour finir :");
    err=scanf("%f",&v);
    if(err<=0)break; /*scanf rend le nombre de variables lues sans erreur*/
    actu=(adr_comp)malloc(sizeof(type_comp));
    actu->val=v;
    if(prem==NULL)prem=actu; else prec->suiv=actu;
    prec=actu;
   }
  while(1);
  actu->suiv=NULL;
  return(prem);
 }
Cette fonction crée la liste en mémoire, effectue la saisie et retourne l'adresse du début de la liste.

Il nous reste à traiter les suppressions dans une liste. Il faut ici encore préciser l'élément précédent celui à supprimer, et traiter de manière particulière le début de la liste :

void suppr_suivant(adr_comp prec)
 {
  adr_comp a_virer;
  if(prec==NULL || prec->suiv==NULL)
    {puts("rien à supprimer");return;}
  a_virer=prec->suiv;
  prec->suiv=a_virer->suiv;
  free(a_virer);
 }

void suppr_premier(adr_comp *prem)
 {
  adr_comp a_virer;
  if(*prem==NULL) {puts("rien à supprimer");return;}
  a_virer=*prem;
  *prem=(*prem)->suiv;
  free(a_virer);
 }
La suppression complète d'une liste permet de récupérer la place en mémoire. Cette opération n'est pas nécessaire en fin de programme, le fait de quitter un programme remet la mémoire dans son état initial et libère donc automatiquement toutes les mémoires allouées dynamiquement :
void suppr_tout(adr_comp prem)
/* attention : ne met pas NULL dans prem qui pointe donc toujours sur la liste, qui n'est plus allouée mais dont le contenu n'a peut-être pas changé */
 {
  adr_comp deuxieme;
  while(prem!=NULL)
   {
    deuxieme=prem->suiv;
    free(prem);
    prem=deuxieme;
   }
 }
Il n'était à priori pas obligatoire d'utiliser la variable deuxième car la libération par free n'efface pas les mémoires, le suivant est donc encore disponible après free et avant tout nouveau malloc. Néanmoins cette écriture est plus sure, ne gérant pas la mémoire nous même (sauf si méthode du super-tableau), en particulier en cas de multitâche, d'interruption matérielle...

On trouvera un exemple utilisant ces fonctions dans test_lst.c (disquette d'accompagnement)

les tris

Seuls les tris n'utilisant pas l'accès direct pourront être efficaces pour les listes. Au lieu de déplacer les valeurs dans la liste, on changera uniquement les liens. Dans la plupart des configurations, le tri par insertion sera le plus efficace.

le tri bulle

Le tri bulle est donc assez efficace (n'ayant pas de lien sur la composante précédente, on mémorisera toujours l'adresse du précédent dans une variable auxiliaire). Mais il reste peu efficace lorsque des éléments sont loin de leur position finale, puisque chaque passage ne peut déplacer un élément que d'une position (de l'ordre de N2/2 échanges, autant de boucles internes), on le réservera donc aux listes presque triées. Les autres tris, comme le tri par insertion déplaceront chaque élément directement en bonne position, mais le temps nécessaire à la recherche peut être assez long, alors qu'ici la position destination est directement connue (mais pas nécessairement juste) (bull_lst) :
void tri_bulle_lst(adr_comp *prem)
/* le premier ne sera peut-être plus le même donc passage par adresse */
 {
  int ok;
  adr_comp prec,actu,suiv;
  do
   {
    ok=1; /* vrai */
    prec=NULL;
    actu=*prem;
    suiv=actu->suiv;
    while(suiv!=NULL)
     {
      if(actu->val > suiv->val)
       {
        ok=0;
        if(prec!=NULL) prec->suiv=suiv; else *prem=suiv;
        actu->suiv=suiv->suiv;
        suiv->suiv=actu;
       }
      prec=actu;
      actu=suiv;
      suiv=actu->suiv;
     }
   }
  while(!ok);
 }
En utilisant prec->suiv et prec->suiv->suiv, on pouvait éviter d'utiliser les variables actu et suiv. Le temps d'accès aux variables aurait été un peu plus long mais on supprimait deux des trois affectations situées en fin de boucle.

le tri par insertion

Le tri par insertion sera bien plus intéressant, dans la majorité des cas : il nécessite une boucle principale (séquentielle), dans laquelle on appelle une seule recherche pour placer l'élément, les déplacements se faisant très rapidement et sans s'occuper du reste de la liste (inse_lst) :
void tri_insertion_lst(adr_comp *prem)
 {
  /*position testée, précédent,dernier plus petit*/
  adr_comp pt,prec,dpp;

  for(prec=*prem,pt=(*prem)->suiv;pt!=NULL;prec=pt,pt=pt->suiv)
   if(prec->val>pt->val) /*inutile de chercher si en bonne
position */
   {
    prec->suiv=pt->suiv;
    if((*prem)->val > pt->val) /*cas particulier du premier*/
     {
      pt->suiv=*prem;
      *prem=pt;
     }
    else
     {
      dpp=*prem;
      while(dpp->suiv->val <= pt->val)dpp=dpp->suiv;
 /* on est sur d'en trouver un, vu les tests effectués plus haut */
      pt->suiv=dpp->suiv;
      dpp->suiv=pt;
     }
   }
 }
Ici également, on aurait pu tenter d'éviter l'utilisation des deux variables prec et pt puisque pt est le suivant de prec, mais les échanges auraient alors nécessité une variable tampon. Cette version du tri est stable (le nouveau est inséré derrière les valeurs égales), mais ceci ralentit un peu la recherche de la position définitive d'une valeur en cas de nombreuses valeurs égales (si la stabilité n'est pas nécessaire, il suffit d'arrêter la recherche au premier élément supérieur ou égal). Dans tous les cas, on fera au maximum N échanges (aucun en bonne position), en moyenne N/2 pour les fichiers mélangés. Dans la cas d'un fichier mélangé, le nombre de boucles internes est de N(N-1)/2 en moyenne. Dans le cas d'un fichier presque trié, dans le cas de quelques éléments (en nombre P) pas du tout à leur place, ce tri est très efficace : P échanges, (P+2)*N/2 boucles internes, donc on devient linéaire en N. Par contre dans le cas de nombreux éléments pas tout à fait à leur place (à la distance D), il l'est moins que le tri bulle, la recherche de la position exacte se faisant à partir du début de la liste (proche de N2 : (N-D)(N-1) boucles internes). Dans ce cas, si l'on peut également disposer d'un chaînage arrière, on fera la recherche à partir de la position actuelle, ce qui rendra le tri très efficace également dans ce cas : D(N-1) boucles internes donc linéaire en N. Sinon, on peut mémoriser la position du Dième précédent, le comparer à la valeur à insérer et donc dans la majorité des cas (si D bien choisi) rechercher derrière lui, dans les quelques cas où il faut l'insérer devant lui, on effectue une recherche depuis le début.

le tri par sélection

Le tri par sélection prendra environ N2/2 boucles internes, et ceci quel que soit l'ordre initial du fichier. Le nombre maximal d'échanges sera de N, N/2 en moyenne pour un fichier mélangé, P pour uniquement P éléments mal placés (sele_lst) :
void tri_selection_lst(adr_comp *prem)
 {
  type_comp faux_debut;
  adr_comp pdpd,pdpp,i;
  faux_debut.suiv=*prem;
  for(pdpd=&faux_debut;pdpd->suiv!=NULL;pdpd=pdpd->suiv)
   {
    /*recherche du plus petit (du moins son précédent)*/
    pdpp=pdpd; /*le plus petit est pour l'instant l'actuel, on va tester les suivants*/
    for(i=pdpd->suiv;i->suiv!=NULL;i=i->suiv)
           if(i->suiv->val < pdpp->suiv->val)pdpp=i;
    /* échange (si beaucoup d'éléments
déjà en place, rajouter un test pour ne pas échanger
inutilement) */
    i=pdpp->suiv;
    pdpp->suiv=i->suiv;
    i->suiv=pdpd->suiv;
    pdpd->suiv=i;
   }
  *prem=faux_debut.suiv; /* retourner le bon premier */
 }
pdpd représente le Précédent De la Place Définitive (boucle principale : de place définitive valant *prem jusqu'à fin de la liste), pdpp représente le Précédent Du Plus Petit (de la suite de la liste, pas encore traitée). On a dans cette implantation évité les variables pour la place définitive et le plus petit, ceux-ci étant les suivants des variables précisées ci-dessus. Ceci nuit à la clarté mais améliore l'efficacité. On pouvait améliorer la clarté (définir les "variables" pd et pp) sans nuire à l'efficacité (ne pas devoir les remplacer par leur suivant en fin de boucle) par des #define :
#define pd pdpd->suiv
#define pp pdpp->suiv
Pour limiter la portée de ces substitutions à cette fonction, il suffit de déclarer juste derrière la fin de la fonction :
#undef pd
#undef pp
De plus, afin d'éviter de tester partout le cas particulier du premier de la liste, on a choisi d'ajouter un élément en début de liste (faux_début), pointant sur le premier de la liste. Dans ce cas, tous les éléments y compris le premier ont un précédent. Le gain est donc appréciable (en temps car moins de tests et en taille du code source), pour un coût limité à une variable locale supplémentaire.

le tri par création

Ce tri nécessite de recréer tous les liens. En fait on utilisera un algorithme par insertion (ou par sélection) un peu modifié, puisque ces deux tris créent progressivement la liste triée. en cas de valeurs de grande taille, on préférera ne créer qu'une nouvelle liste de liens, pour éviter de doubler la place mémoire utilisée par les valeurs (la composante de base contiendra donc deux pointeurs : l'adresse de la valeur et l'adresse de la composante suivante).

les autres tris

Le tri shell, quand à lui, n'améliore pas le tri par sélection puisqu'il traite les composantes séparées d'une distance P.

Pour le tri rapide, le nombre de boucles internes est de l'ordre de Nlog(N) (N sur toute la liste, puis deux fois N/2 sur les deux moitiés.... donc N*P, P étant la profondeur de récursivité, qui est de log(N)). Par contre les échanges sont plus nombreux que les autres tris (en moyenne une boucle interne sur deux pour un fichier mélangé). Pour transposer l'implantation détaillée pour les tableaux, il faut disposer d'une liste à chaînage avant et arrière, mais il est facile de n'utiliser que le chaînage avant : l'algorithme devient donc :

Cette gestion des liens ralentit beaucoup l'algorithme, son implantation doit donc être soigneusement optimisée pour espérer un gain, du moins pour les très longues listes.

problèmes mathématiques

On utilisera les listes pour les cas nécessitant de nombreuses manipulations (en particulier dans les problèmes graphiques). On les utilise également pour les polynômes ou matrices creuses (c'est à dire avec de nombreux coefficients nuls, on ne stocke que les coefficients non nuls (ainsi que leur indice ou le nombre de 0 qui les séparent du suivant). Les algorithmes restent similaires à ceux s'appliquant aux tableaux, mais sont souvent moins efficaces (une triangulation de Gauss rend non nuls une grande partie des coefficients nuls, nécessitant de nombreuses insertions de nouveaux éléments).

conclusions

Les listes sont parfaitement dynamiques. Toutes les modifications peuvent se faire en cours d'utilisation. Par contre l'accès est séquentiel, ce qui peut être très pénalisant dans certains cas. Il est néanmoins possible d'utiliser un double chaînage avant et arrière, mais au détriment de la place mémoire. L'encombrement des listes en mémoire est important : chaque élément doit contenir l'adresse du suivant (alors que pour les tableaux elle était facile à calculer donc non stockée). Pour de très grandes listes, on peut remédier à ce problème en utilisant des listes de tableaux, mais l'utilisation devient complexe. Un autre avantage des listes est que l'on utilise toujours un adressage indirect, et donc que les manipulations dans les listes ne font que des modifications du chaînage, sans réellement déplacer les valeurs stockées, ce qui est capital en cas de champs de grande taille.


précédent suivant haut Contents Index