précédent suivant haut Contents Index

LES ARBRES


introduction

Les listes et piles sont des structures dynamiques unidimensionnelles. Les arbres sont leur généralisation multidimensionnelle (une liste est un arbre dont chaque élément a un et un seul fils). On utilise un vocabulaire inspiré des arbres généalogiques (sexiste il est vrai, bien qu'on utilise quelquefois les termes fille et mère). Chaque composante d'un arbre contient une valeur, et des liens vers ses fils. On peut distinguer un noeud, qui est une composante ayant des fils, et une feuille, qui n'en possède pas (mais est fils d'un noeud) mais souvent on préfère utiliser un type noeud y compris pour les feuilles (tous les fils sont mis à NULL), surtout si l'arbre est évolutif (une feuille peut devenir un noeud, et inversement). Une branche rassemble un lien vers un fils mais aussi ce fils et toute sa descendance. Le noeud racine est le seul qui n'est le fils d'aucun noeud (tous les autres noeuds sont donc sa descendance). Comme pour le premier d'une liste, l'adresse de la racine est nécessaire et suffisante pour accéder à l'intégralité d'un arbre. Un arbre N-aire ne contient que des noeuds à N fils (et des feuilles). Une liste est donc un arbre unaire. De nombreux algorithmes ont été développés pour les arbres binaires (nous verrons que tout arbre peut être représenté par un arbre binaire). Comme les listes, les arbres sont complètement dynamiques, mais les liens sont également gourmands en mémoire. L'accès aux données est encore séquentiel, mais on verra qu'il reste néanmoins rapide.

expressions arithmétiques (arb_expr)

Nous allons détailler les fonctionnalités de base des arbres à l'aide d'un exemple : la représentation et l'évaluation d'expressions arithmétiques. Nous supposerons (voir chapitre sur les piles) utiliser uniquement des opérandes flottants, les opérateurs deuxaires +,-,*,/ et les opérateurs unaires C (pour cosinus) et S (sinus). L'expression 2*5 + 2*{cos[(5*3.14)/180]} se représentera :

arbre

Les composantes de l'arbre seront donc soit des opérateurs (noeuds), soit des opérandes (feuilles). Nous allons choisir les déclarations suivantes :

typedef float type_feuille;
typedef struct s_noeud_2
  {char op_c;
   struct s_comp*fils1;
   struct s_comp*fils2;
  }type_noeud_2;
typedef struct s_noeud_1
  {char op_c;
   struct s_comp*fils;
  }type_noeud_1;
typedef struct s_comp
    {int arite;
     union
         {type_noeud_2 n2;
          type_noeud_1 n1;
          type_feuille f;
         }val;
    }composante;
typedef composante *lien;
Nous prévoyons donc deux types de noeuds : à arité 1 (avec un fils) ou à arité 2 (deux fils). On aurait pu utiliser un seul type (à arité 2) en mettant NULL dans le second fils en cas d'arité 1. Une feuille par contre ne contient aucun lien. Une composante de l'arbre contiendra donc un indicateur de son type (ici l'arité) et, suivant ce type, soit une feuille, soit un noeud (d'arité 1 ou 2). Nous allons écrire une fonction créant un tel arbre (à partir d'une chaîne de caractères préalablement saisie, contenant l'expression en notation polonaise préfixée par exemple) :
lien saisie_prefixe(char **deb)
/* on donne une chaîne de char contenant l'expression en notation préfixée. les opérateurs possibles sont +,-,*,/(deuxaires), C,S(cos,sin,unaires). Les nombres sont séparés par des blancs (optionnels pour les opérateurs) Les nombres commencent par un chiffre (exemple 0.5 au lieu de .5).*/
/* deb pointe sur le début de chaîne, il pointera ensuite sur le reste de la chaîne (pas encore traitée) donc passage par adresse d'un pointeur */
 {
  lien x;
  char c;
  while(**deb==' '||**deb==',') (*deb)++;
  if(**deb==0) /* on est arrivé en fin de chaîne */
      {puts("erreur : il doit manquer des opérandes");
       return(NULL);}
  x=(composante*)malloc(sizeof(composante));
  if(isdigit(**deb))
   {
    x->arite=0;
    sscanf(*deb,"%f",&(x->val.f));
    while(isdigit(**deb)||**deb=='.') (*deb)++;
   }
  else
   {
    c=toupper(*((*deb)++));
    if(c=='*'||c=='/'||c=='+'||c=='-')
     {
      x->arite=2;
      x->val.n2.op_c=c;
      x->val.n2.fils1=saisie_prefixe(deb);
      x->val.n2.fils2=saisie_prefixe(deb);
     }
    else if(c=='C'||c=='S')
     {
      x->arite=1;
      x->val.n1.op_c=c;
      x->val.n1.fils=saisie_prefixe(deb);
     }
    else printf("erreur, '%c'n'est pas un opérateur prévu\n",c);
   }
  return(x);
 }
On peut remarquer que cette fonction est récursive. La création de l'arbre à partir d'une expression postfixée est similaire (scruter la chaîne dans l'autre sens par exemple), à partir d'une expression infixée c'est un peu plus dur, surtout si l'on ne veut pas imposer d'entrer toutes les parenthèses, par exemple 1+2+3 au lieu de (1+(2+3)).

L'utilisation d'un tel arbre devient maintenant très simple. On utilisera la récursivité. Pour évaluer l'expression, si c'est une feuille unique la valeur est celle de la feuille, si c'est un noeud, la valeur est obtenue en effectuant l'opération correspondante, après évaluation (récurrente, donc soit directement la valeur si feuille soit nouveau calcul) de ses fils :

float eval(lien x)
 {
  float r1,r2;
  if(x->arite==0) return(x->val.f);
  else if (x->arite==1)
   {
    if (x->val.n1.op_c=='C')
         return(cos(eval(x->val.n1.fils)));
    else if (x->val.n1.op_c=='S')
         return(sin(eval(x->val.n1.fils)));
   }
  else
   {
    r1=eval(x->val.n2.fils1);
    r2=eval(x->val.n2.fils2);
    switch (x->val.n2.op_c)
     {
      case '+':return(r1+r2);
      case '-':return(r1-r2);
      case '*':return(r1*r2);
      case '/':return(r1/r2);
     }
   }
  /* si aucun return n'a été effectué jusqu'ici*/
  puts("erreur (code opératoire inconnu par exemple)");
  return(0);
 }
Le parcours des arbres (c'est à dire le passage par tous les noeuds et feuilles) se fait d'habitude de manière récursive. On doit évidement parcourir l'arbre pour afficher son contenu. On peut utiliser dans le cas des arbres binaires trois possibilités de parcours : afficher la valeur du noeud puis récursivement de ses deux fils, afficher le premier fils, la valeur du noeud, puis le second fils, ou afficher les deux fils avant la valeur du noeud. On obtient dans notre cas directement les notations préfixé, infixée et postfixée (avec un petit travail supplémentaire pour la gestion des parenthèses en notation infixée, qu'on aurait pu simplifier si l'on avait accepté des parenthèses surabondantes) :
void affiche_prefixe(lien x)
 {
  switch(x->arite)
   {
    case 0:printf("%6.1f ",x->val.f);break;
    case 1:printf(" %C ",x->val.n1.op_c);
           affiche_prefixe(x->val.n1.fils);
           break;
    case 2:printf(" %c ",x->val.n2.op_c);
           affiche_prefixe(x->val.n2.fils1);
           affiche_prefixe(x->val.n2.fils2);
   }
 }

void affiche_postfixe(lien x)
 {
  switch(x->arite)
   {
    case 0:printf("%6.1f ",x->val.f);break;
    case 1:affiche_postfixe(x->val.n1.fils);
           printf(" %C ",x->val.n1.op_c);
           break;
    case 2:affiche_postfixe(x->val.n2.fils1);
           affiche_postfixe(x->val.n2.fils2);
           printf(" %c ",x->val.n2.op_c);
   }
 }

void affiche_infixe(lien x)
 {
  switch(x->arite)
   {
    case 0:printf("%6.1f ",x->val.f);break;
    case 1:printf(" %C( ",x->val.n1.op_c);
           affiche_infixe(x->val.n1.fils);
           putch(')');
           break;
    case 2:if(x->val.n2.fils1->arite!=0)putch('(');
           affiche_infixe(x->val.n2.fils1);
           if(x->val.n2.fils1->arite!=0)putch(')');
           printf(" %c ",x->val.n2.op_c);
           if(x->val.n2.fils2->arite!=0)putch('(');
           affiche_infixe(x->val.n2.fils2);
           if(x->val.n2.fils2->arite!=0)putch(')');
   }
 }
Notre exemple est un peu plus complexe que dans véritable arbre binaire, puisque nous acceptions également des noeuds à un seul fils.

listes triées

Il est possible de mémoriser une liste ordonnée à l'aide d'un arbre binaire. Chaque noeud de l'arbre contient une valeur et deux liens : l'un vers un sous-arbre ne contenant que des valeurs inférieures, l'autre vers des valeurs supérieures. Nous allons traiter cette fois des chaînes de caractères plutôt que des réels. Nous définirons donc les types suivants :
typedef struct s_noeud *lien;
typedef struct s_noeud
  {char *nom;
   lien fils1;
   lien fils2;
  }type_noeud;
Il vaut mieux ne pas prévoir de type spécifique pour les feuilles, l'arbre étant géré de manière évolutive (une feuille sera donc un noeud avec ses deux fils valant le pointeur NULL). Le nom contenu dans chaque noeud aurait pu être un tableau statique de caractères, mais pour optimiser le coût mémoire, nous utiliserons un tableau dynamique. Donc, pour allouer la mémoire d'un nouveau noeud, deux malloc seront nécessaires : un pour le noeud lui-même, et un pour le nom qui lui est associé. Ecrivons une fonction de création d'un noeud (on lui transmet le nom associé, elle retourne l'adresse allouée):
lien cree_feuille(char *nouv_nom)
 {
  lien x;
  int taille;
  x=(lien)malloc(sizeof(type_noeud));
  taille=strlen(nouv_nom)+1; /* compter le \0 */
  x->nom=(char*)malloc(taille);
  strcpy(x->nom,nouv_nom);
  x->fils1=NULL;
  x->fils2=NULL;
  return(x);
 }
Supposons entrer dans l'ordre : Jean, Bernard, Lucie, Anne, Jacques, Laurent, Maud, Luc, Julie, Elsa. Si vous créez progressivement l'arbre, vous pourrez voir que toute nouvelle valeur trouve toujours sa place, les feuilles se transformant petit à petit en noeuds, au fur et à mesure de l'augmentation de la taille de l'arbre.

arbre binaire

La fonction effectuant la saisie et la création de cet arbre devra donc, après la saisie du nouveau nom à insérer, rechercher sa position définitive. Cette recherche sera récursive: sur un noeud donné, si le nouveau nom est plus petit, rechercher dans la descendance du fils1, sinon rechercher dans la descendance du fils2. La recherche s'arrête quand on arrive à un pointeur NULL (qui sera remplacé par l'adresse du nouveau noeud). En cas d'égalité, si l'on désire une création stable, un nouveau nom sera placé derrière ceux déjà existants. :

void insert_feuille(lien racine, lien x)
 {
  while(racine!=NULL) /* ou boucle infinie */
   {
    if(strcmp(x->nom,racine->nom)<0)
      if(racine->fils1==NULL){racine->fils1=x;return;}
      else racine=racine->fils1;
    else
       if(racine->fils2==NULL){racine->fils2=x;return;}
       else racine=racine->fils2;
   }
 }

lien saisie(void)
 {
  lien racine=NULL;
  char txt[100];
  do
   {
    printf("entrez un nouveau nom, @@@ pour finir : ");
    gets(txt);
    if(strcmp(txt,"@@@"))
      if(racine==NULL)racine=cree_feuille(txt);
      else insert_feuille(racine,cree_feuille(txt));
   }
  while(strcmp(txt,"@@@"));
  return(racine);
 }
On remarque l'efficacité de cette méthode : aucun déplacement d'élément, le tri se fait par insertion, mais avec recherche optimisée (du type dichotomie : l'espace de recherche est réduit à chaque test). Pour N insertions, on fait donc N recherches donc N*P lectures de composantes (P profondeur de l'arbre).

Une fois l'arbre créé, on peut afficher la liste triée par ordre alphabétique par un simple parcours infixé (arbr_nom):

void affiche(lien x)
 {
  if(x!=NULL)
   {
    affiche(x->fils1);
    printf("%s, ",x->nom);
    affiche(x->fils2);
   }
 }
L'utilisation d'un arbre binaire pour une liste triée permet donc une programmation relativement aisée, des algorithmes rapides, mais moyennant un peu de place mémoire supplémentaire. L'arbre va posséder simultanément tous les avantages des autres structures de données: Par contre l'arbre binaire nécessite d'être équilibré pour profiter pleinement de ces avantages. Un arbre équilibré est un arbre organisé de telle manière à ce que sa profondeur soit minimale. A l'extrême, en cas d'introduction d'une liste de noms déjà triée, tous les fils1 pointeront sur NULL alors que les fils2 pointeront sur le suivant, on se retrouve dans le cas d'une liste chaînée simple. Différents algorithmes d'équilibrage d'arbres binaires existent, mais en général un algorithme simple permet un résultat satisfaisant (sauf en cas d'un très grand nombre de données), l'équilibre parfait n'étant pas nécessaire.

les arbres généraux

Quand le nombre de fils de chaque élément est variable, on peut soit prévoir un tableau statique des adresses des fils, soit prévoir un tableau dynamique, ce qui optimise l'occupation mémoire mais complique l'ajout de fils supplémentaires. Pour avoir une gestion parfaitement dynamique, on peut créer une liste des adresses des fils :

arbre

En fait, plutôt que de créer cette liste hors des noeuds, le plus simple (et qui utilise autant de mémoire) est d'associer à chaque noeud l'adresse de son fils aîné, et de son frère cadet. Accéder à tous les fils revient donc à accéder au fils aîné puis à tous ses frères:

arbre binaire

On peut remarquer qu'un tel arbre est un arbre binaire: chaque noeud possède deux liens. On peut donc traiter tout arbre sous forme d'un arbre binaire.

Une autre amélioration possible d'un arbre est de permettre un accès toujours séquentiel mais bidirectionnel : pour un arbre binaire, chaque noeud possède en plus l'adresse de son père, pour un arbre généralisé ceci revient à ce que chaque frère connaît son aîné, l'aîné connaissant leur père commun. Cet accès bidirectionnel est coûteux en mémoire, mais complique également les modifications (plus de liens à gérer), pour par contre accélérer les parcours dans l'arbre. On ne prévoira cette amélioration que lorsque l'on utilise de fréquentes remontées (l'utilisation d'algorithmes récursifs ne nécessite en général pas ces liens, le fait de quitter une fonction appelée pour gérer un fils fait automatiquement retrouver le père).


précédent suivant haut Contents Index