LES LISTES


fonctions de base et manipulations (base_lst)

Les listes sont un regroupement ordonne de donnees effectue de maniere a ce que chaque composante sache ou se trouve la suivante. En C, une composante sera une structure contenant la valeur memorisee, mais egalement un pointeur sur la composante suivante. Sur fichier ou dans les langages ne possedant pas de pointeurs, on utilisera la methode du super-tableau, le pointeur etant remplace par l'indice, dans le super-tableau, de la composante suivante. Une liste est accessible par l'adresse de sa premiere composante. On supposera dans la suite que les valeurs a memoriser sont de type flottant, mais evidement tout autre type de donnees est possible (meme tableaux, structures, listes...). La declaration 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 representerons les composantes des listes (de type type_comp) ainsi :

element de liste

Supposons disposer de la liste ci-dessous en memoire (nous verrons plus loin comment la creer en memoire). La variable prem, de type adr_comp (donc pointeur sur une composante) contient l'adresse de la premiere composante. Le champ suiv de la derniere composante contient la valeur NULL, donc ne pointe sur rien.

liste*

Pour simplifier les algorithmes, on peut mettre d'office une composante particuliere en debut et fin de liste (appelee sentinelle). Ceci evite de devoir traiter de maniere particuliere le premier et dernier element de la liste, puisque chaque composante utile possede toujours un precedent et un suivant. Cette methode prend un peu plus de memoire (negligeable en % pour de longues listes) et evite des tests systematiques dans le boucles, alors qu'ils ne servent que pour les extremites (d'ou gain de temps appreciable, toujours en cas de longues listes). Une autre methode pour reperer le dernier element est de le faire pointer sur lui-meme. Nous utiliserons dans la suite des listes sans sentinelle, le dernier element pointant sur NULL.

L'appel de : affiche_lst(prem) affichera a l'ecran 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 premiere composante. On affiche la valeur pointee par l puis on fait pointer l sur son suivant. On repete le processus jusqu'en fin de liste (lorsque l'on pointe sur NULL). La variable l doit etre locale (donc passee par valeur) pour ne pas modifier le contenu de prem, et donc perdre l'adresse du debut de la liste, ce qui empecherait tout acces ulterieur a la liste.

Comme pour les chaines de caracteres, plutot que de gerer un variable entiere indiquant toujours la longueur actuelle de la liste, c'est la specificite du dernier element (ici, le champ suiv contient NULL) qui permet de preciser que l'on est en fin de liste. Pour connaitre 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'etre reellement dynamiques, c'est a dire que l'on peut a loisir les rallonger ou les raccourcir, avec pour seule limite la memoire disponible (ou la taille du super-tableau). Par exemple pour inserer une nouvelle composante en debut 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 inserant la valeur 5 a notre exemple precedent, on obtient la liste :

insere

La variable prem a du etre passee par adresse, pour que l'on recupere bien l'adresse de la nouvelle premiere composante (appel : insert_premier(&prem,5)). En fait le schema ci-dessus n'est qu'une representation abstraite de la liste (chaque composante pointe sur la suivante), alors que les composantes sont physiquement placees differemment en memoire. Dans le meilleur des cas, la nouvelle composante creee a ete placee juste derriere celles deja existantes (premier emplacement libre). Un schema plus proche de la realite serait donc :

ordonner

Ces deux schemas sont equivalents (les liens partent des memes cases, et pointent sur les memes cases), seule la disposition des cases differe. En fait ceci nous montre bien que la disposition reelle en memoire ne nous interesse pas (jamais la valeur effective de l'adresse contenue dans un pointeur ne nous interessera). Dans nos schemas, nous choisirons donc une disposition des composantes correspondant plus au probleme traite qu'a la disposition reelle en memoire (par exemple dans le schema suivant, on utilise une representation bidimensionnelle, alors que la memoire n'est qu'unidimensionnelle).

Le second avantage des listes est que l'insertion d'une composante au milieu d'une liste ne necessite que la modification des liens avec l'element precedent et le suivant, le temps necessaire sera donc independant 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_apres(X,15) donnera le resultat suivant :
insertion

void insert_apres(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 premiere position (qui n'a pas de precedent dans notre cas puisque sans sentinelle), il faut traiter specifiquement le premier de la liste (voir plus haut). L'insertion necessite la connaissance de l'adresse du precedent et du suivant. Les composantes ne comportant que des liens vers l'element suivant, il faut necessairement donner a la fonction insert_apres l'adresse du precedent, alors que d'habitude on prefere donner l'element devant lequel on veut faire l'insertion (tableaux par exemple). Ceci nous montre le principal probleme des listes : l'acces est sequentiel. On devra, pour chercher la composante precedente, parcourir toute la liste depuis son debut (on donne l'adresse du debut de la liste et de l'element dont on cherche le precedent):
adr_comp rech_prec(adr_comp prem, adr_comp suiv)
/* rend l'adresse, NULL si non trouve */
 {
  while(prem->suiv!=suiv && prem!=NULL)
     prem=prem->suiv;
  return(prem);
 }

Une autre solution, si l'on a frequemment besoin de parcourir la liste vers l'avant et vers l'arriere, est de stocker dans chaque composante l'adresse du suivant et celle du precedent. Ceci necessite plus de place en memoire et ralentit un peu les manipulations de base (il y a plus de liens a mettre a jour). Mais meme dans ce cas, l'acces reste sequentiel. Ceci empeche d'utiliser un certain nombre d'algorithmes utilisant l'acces direct (la dichotomie par exemple). Pour trouver l'adresse du Nieme element d'une liste il faudra utiliser (on suppose numeroter 0 le premier) :
adr_comp rech_ind(adr_comp l, int i)
/* rend l'adresse du (i+1)ieme, NULL si liste trop courte */
 {
  int j;
  for(j=0;j<i && l!=NULL;j++) l=l->suiv;
  return(l);
 }
La recherche d'un element, meme si la liste est triee, se fera donc toujours sequentiellement :
adr_comp rech_val(adr_comp l, type_val v)
/* rend l'adresse, NULL si non trouve */
 {
  while(l!=NULL && l->val!=v) l=l->suiv;
  return(l);
 }
Pour creer une liste, la solution la plus simple consiste a boucler sur un appel de la fonction insert_premier, mais les elements seront stockes dans l'ordre inverse de leur introduction (le dernier saisi sera place en premier). Pour une creation de liste dans l'ordre, on fera :
adr_comp saisie_lst(void)
 {
  adr_comp prem=NULL,prec,actu; /* premier, precedent, actuel*/
  type_val v;
  int err;
  do
   {
    printf("entrez votre prochaine valeur, un caractere 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 cree la liste en memoire, effectue la saisie et retourne l'adresse du debut de la liste.

Il nous reste a traiter les suppressions dans une liste. Il faut ici encore preciser l'element precedent celui a supprimer, et traiter de maniere particuliere le debut de la liste :

void suppr_suivant(adr_comp prec)
 {
  adr_comp a_virer;
  if(prec==NULL || prec->suiv==NULL)
    {puts("rien a 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 a supprimer");return;}
  a_virer=*prem;
  *prem=(*prem)->suiv;
  free(a_virer);
 }
La suppression complete d'une liste permet de recuperer la place en memoire. Cette operation n'est pas necessaire en fin de programme, le fait de quitter un programme remet la memoire dans son etat initial et libere donc automatiquement toutes les memoires allouees 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 allouee mais dont le contenu n'a peut-etre pas change */
 {
  adr_comp deuxieme;
  while(prem!=NULL)
   {
    deuxieme=prem->suiv;
    free(prem);
    prem=deuxieme;
   }
 }

Il n'etait a priori pas obligatoire d'utiliser la variable deuxieme car la liberation par free n'efface pas les memoires, le suivant est donc encore disponible apres free et avant tout nouveau malloc. Neanmoins cette ecriture est plus sure, ne gerant pas la memoire nous meme (sauf si methode du super-tableau), en particulier en cas de multitāche, d'interruption materielle...

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

les tris

Seuls les tris n'utilisant pas l'acces direct pourront etre efficaces pour les listes. Au lieu de deplacer 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 precedente, on memorisera toujours l'adresse du precedent dans une variable auxiliaire). Mais il reste peu efficace lorsque des elements sont loin de leur position finale, puisque chaque passage ne peut deplacer un element que d'une position (de l'ordre de N2/2 echanges, autant de boucles internes), on le reservera donc aux listes presque triees. Les autres tris, comme le tri par insertion deplaceront chaque element directement en bonne position, mais le temps necessaire a la recherche peut etre assez long, alors qu'ici la position destination est directement connue (mais pas necessairement juste) (bull_lst) :
void tri_bulle_lst(adr_comp *prem)
/* le premier ne sera peut-etre plus le meme 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 eviter d'utiliser les variables actu et suiv. Le temps d'acces aux variables aurait ete un peu plus long mais on supprimait deux des trois affectations situees en fin de boucle.

le tri par insertion

Le tri par insertion sera bien plus interessant, dans la majorite des cas : il necessite une boucle principale (sequentielle), dans laquelle on appelle une seule recherche pour placer l'element, les deplacements se faisant tres rapidement et sans s'occuper du reste de la liste (inse_lst) :
void tri_insertion_lst(adr_comp *prem)
 {
  /*position testee, precedent,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 effectues plus haut */
      pt->suiv=dpp->suiv;
      dpp->suiv=pt;
     }
   }
 }

Ici egalement, on aurait pu tenter d'eviter l'utilisation des deux variables prec et pt puisque pt est le suivant de prec, mais les echanges auraient alors necessite une variable tampon. Cette version du tri est stable (le nouveau est insere derriere les valeurs egales), mais ceci ralentit un peu la recherche de la position definitive d'une valeur en cas de nombreuses valeurs egales (si la stabilite n'est pas necessaire, il suffit d'arreter la recherche au premier element superieur ou egal). Dans tous les cas, on fera au maximum N echanges (aucun en bonne position), en moyenne N/2 pour les fichiers melanges. Dans la cas d'un fichier melange, le nombre de boucles internes est de N(N-1)/2 en moyenne. Dans le cas d'un fichier presque trie, dans le cas de quelques elements (en nombre P) pas du tout a leur place, ce tri est tres efficace : P echanges, (P+2)*N/2 boucles internes, donc on devient lineaire en N. Par contre dans le cas de nombreux elements pas tout a fait a leur place (a la distance D), il l'est moins que le tri bulle, la recherche de la position exacte se faisant a partir du debut de la liste (proche de N2 : (N-D)(N-1) boucles internes). Dans ce cas, si l'on peut egalement disposer d'un chainage arriere, on fera la recherche a partir de la position actuelle, ce qui rendra le tri tres efficace egalement dans ce cas : D(N-1) boucles internes donc lineaire en N. Sinon, on peut memoriser la position du Dieme precedent, le comparer a la valeur a inserer et donc dans la majorite des cas (si D bien choisi) rechercher derriere lui, dans les quelques cas ou il faut l'inserer devant lui, on effectue une recherche depuis le debut.

le tri par selection

Le tri par selection prendra environ N2/2 boucles internes, et ceci quel que soit l'ordre initial du fichier. Le nombre maximal d'echanges sera de N, N/2 en moyenne pour un fichier melange, P pour uniquement P elements mal places (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 precedent)*/
    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;
    /* echange (si beaucoup d'elements
deja en place, rajouter un test pour ne pas echanger
inutilement) */
    i=pdpp->suiv;
    pdpp->suiv=i->suiv;
    i->suiv=pdpd->suiv;
    pdpd->suiv=i;
   }
  *prem=faux_debut.suiv; /* retourner le bon premier */
 }

pdpd represente le Precedent De la Place Definitive (boucle principale : de place definitive valant *prem jusqu'a fin de la liste), pdpp represente le Precedent Du Plus Petit (de la suite de la liste, pas encore traitee). On a dans cette implantation evite les variables pour la place definitive et le plus petit, ceux-ci etant les suivants des variables precisees ci-dessus. Ceci nuit a la clarte mais ameliore l'efficacite. On pouvait ameliorer la clarte (definir les "variables" pd et pp) sans nuire a l'efficacite (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 portee de ces substitutions a cette fonction, il suffit de declarer juste derriere la fin de la fonction :
#undef pd
#undef pp
De plus, afin d'eviter de tester partout le cas particulier du premier de la liste, on a choisi d'ajouter un element en debut de liste (faux_debut), pointant sur le premier de la liste. Dans ce cas, tous les elements y compris le premier ont un precedent. Le gain est donc appreciable (en temps car moins de tests et en taille du code source), pour un cout limite a une variable locale supplementaire.

le tri par creation

Ce tri necessite de recreer tous les liens. En fait on utilisera un algorithme par insertion (ou par selection) un peu modifie, puisque ces deux tris creent progressivement la liste triee. en cas de valeurs de grande taille, on preferera ne creer qu'une nouvelle liste de liens, pour eviter de doubler la place memoire utilisee 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 a lui, n'ameliore pas le tri par selection puisqu'il traite les composantes separees 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 moities.... donc N*P, P etant la profondeur de recursivite, qui est de log(N)). Par contre les echanges sont plus nombreux que les autres tris (en moyenne une boucle interne sur deux pour un fichier melange). Pour transposer l'implantation detaillee pour les tableaux, il faut disposer d'une liste a chainage avant et arriere, mais il est facile de n'utiliser que le chainage avant : l'algorithme devient donc :

Cette gestion des liens ralentit beaucoup l'algorithme, son implantation doit donc etre soigneusement optimisee pour esperer un gain, du moins pour les tres longues listes.

problemes mathematiques

On utilisera les listes pour les cas necessitant de nombreuses manipulations (en particulier dans les problemes graphiques). On les utilise egalement pour les polynomes ou matrices creuses (c'est a 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 separent du suivant). Les algorithmes restent similaires a ceux s'appliquant aux tableaux, mais sont souvent moins efficaces (une triangulation de Gauss rend non nuls une grande partie des coefficients nuls, necessitant de nombreuses insertions de nouveaux elements).

conclusions

Les listes sont parfaitement dynamiques. Toutes les modifications peuvent se faire en cours d'utilisation. Par contre l'acces est sequentiel, ce qui peut etre tres penalisant dans certains cas. Il est neanmoins possible d'utiliser un double chainage avant et arriere, mais au detriment de la place memoire. L'encombrement des listes en memoire est important : chaque element doit contenir l'adresse du suivant (alors que pour les tableaux elle etait facile a calculer donc non stockee). Pour de tres grandes listes, on peut remedier a ce probleme 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 chainage, sans reellement deplacer les valeurs stockees, ce qui est capital en cas de champs de grande taille.