Précédent Remonter Suivant
Chapitre 1 Graphes

L es graphes sont des structures combinatoires rencontrées dans des applications faisant intervenir des mathématiques discrètes et nécessitant une solution informatique. Circuits électriques, réseaux de transport (ferrés, routiers, aériens), réseaux d'ordinateurs, ordonnancement d'un ensemble de tâches sont les principaux domaines d'application où la structure de graphe intervient. D'un point de vue formel, il s'agit d'un ensemble de points (sommets) et d'un ensemble d'arcs reliants des couples de sommets. Une des premières questions que l'on se pose est de déterminer, étant donné un graphe et deux de ses sommets, s'il existe un chemin (suite d'arcs) qui les relie; cette question très simple d'un point de vue mathématique pose des problèmes d'efficacité dès que l'on souhaite la traiter à l'aide de l'ordinateur pour des graphes comportant un très grand nombre de sommets et d'arcs. Pour se convaincre de la difficulté du problème il suffit de considérer le jeu d'échecs et représenter chaque configuration de pièces sur l'échiquier comme un sommet d'un graphe, les arcs joignent chaque configuration à celles obtenues par un mouvement d'une seule pièce; la résolution d'un problème d'échecs revient ainsi à trouver un ensemble de chemins menant d'un sommet à des configurations de ``mat ``. La difficulté du jeu d'échecs provient donc de la quantité importante de sommets du graphe que l'on doit parcourir. Des graphes plus simples comme celui des stations du Métro Parisien donnent lieu à des problèmes de parcours beaucoup plus facilement solubles. Il est courant, lorsque l'on étudie les graphes, de distinguer entre les graphes orientés dans lesquels les arcs doivent être parcourus dans un sens déterminé (de x vers y mais pas de y vers x) et les graphes symétriques (ou non-orientés) dans lesquels les arcs (appelés alors arêtes) peuvent être parcourus dans les deux sens. Nous nous limitons dans ce chapitre aux graphes orientés, car les algorithmes de parcours pour les graphes orientés s'appliquent en particulier aux graphes symétriques : il suffit de construire à partir d'un graphe symétrique G le graphe orienté G' comportant pour chaque arête x,y de G deux arcs opposés, l'un de x vers y et l'autre de y vers x.

1.1 Définitions

Dans ce paragraphe, nous donnons quelques définitions sur les graphes orientés et quelques exemples, nous nous limitons ici aux définitions les plus utiles de façon à passer très vite aux algorithmes.
Définition 1   Un graphe G = (X,A) est donné par un ensemble X de sommets et par un sous-ensemble A du produit cartésien X × X appelé l'ensemble des arcs de G.

Un arc a=(x,y) a pour origine le sommet x et pour extrémité le sommet y. On note
org(a) = x    ext(a) = y
Dans la suite, on suppose que tous les graphes considérés sont finis, ainsi X et par conséquent A sont des ensembles finis. On dit que le sommet y est un successeur de x si (x,y) Î A, on dit aussi que x est un prédécesseur de y.

Définition 2   Un chemin f du graphe G=(X,A) est une suite d'arcs á a1,a2,...,apñ telle que:
" i, 1 £ i< p    org(ai+1) = ext(ai)

L'origine d'un chemin f, aussi notée org(f), est celle de son premier arc a1 et son extrémité, notée ext(f) est celle de son dernier arc ap. La longueur du chemin est égale au nombre d'arcs qui le composent, c'est-à-dire p. Un chemin f tel que org(f)=ext(f) est appelé un circuit.
Exemple 1  Graphes de De Bruijn. Les sommets sont les suites de longueur k formées de symboles 0 ou 1, un arc joint la suite f à la suite g si f=xh et g=hyx et y sont des symboles (0 ou 1) et où h est une suite quelconque de k-1 symboles.

    

Figure 1.1 : Graphe de De Bruijn pour k=3; graphe des diviseurs pour n=12



Exemple 2  Graphes des diviseurs. Les sommets sont les nombres {2,3,..., n}, un arc joint p à q si p divise q.
1.2 Matrices d'adjacence

Une structure de données simple pour représenter un graphe est la matrice d'adjacence M. Pour obtenir M, on numérote les sommets du graphe de façon quelconque:
X= {x1, x2... xn }

M est une matrice booléenne carrée n × n dont les coefficients sont V et F telle que:
Mi,j = ì
í
î
V   si   (xi,xj) Î A
F   sinon

Ceci donne alors les déclarations de type et de variables suivantes:

class GrapheMat {
  boolean[ ][ ] m;    // la matrice M d'adjacence, 
  GrapheMat (int n) {
      m = new boolean[n][n];
  }
}

  
M = æ
ç
ç
ç
ç
ç
ç
è
F V V F F F
F F V V F V
F F F F F V
F F F F V F
F V F F F F
F F F V F F
ö
÷
÷
÷
÷
÷
÷
ø

Figure 1.2 : Un graphe avec sa matrice d'adjacence



La matrice d'adjacence aurait aussi pu être représentée par une matrice d'entiers (0 pour F, 1 pour V). Un intérêt de cette autre représentation est que la détermination de chemins dans G revient au calcul des puissances successives de la matrice M comme le montre le théorème suivant.
Théorème 3   Soit Mp la puissance p-ième de la matrice M, le coefficient Mi,jp est égal au nombre de chemins de longueur p de G dont l'origine est le sommet xi et dont l'extrémité est le sommet xj.

Démonstration  Par récurrence sur p. Pour p=1, le résultat est immédiat car un chemin de longueur 1 est un arc du graphe. Pour p > 1, le calcul de Mp donne:
Mi,jp =
n
å
k=1
Mi,kp-1 Mk,j
Or tout chemin de longueur p entre xi et xj se décompose en un chemin de longueur p-1 entre xi et un certain xk suivi d'un arc reliant xk et xj. Le résultat découle alors de l' hypothèse de récurrence suivant laquelle Mi,kp-1 est le nombre de chemins de longueur p-1 joignant xi à xk.

De ce théorème, on déduit l'algorithme suivant permettant de tester l'existence d'un chemin entre deux sommets x et y:

static boolean existeChemin (GrapheMat g, int x, int y) {
  int n = g.m.length;
  boolean r[ ][ ] = new boolean[n][n];
  copie(r, m);
  for (int p = 1; !r[x][y] && p < n; ++p) {
    multiplier (r, r, m);
    additionner (r, r, m);
  }
  return r[x][y];
}
Les fonctions multiplier(r,a,b) et additionner(r,a,b) sont respectivement des fonctions qui multiplient et ajoutent les deux matrices booléennes a et b (de dimension n × n) en rangeant le résultat dans la matrice r. La fonction copie(r,m) fait une copie de m dans la matrice r.

Le nombre d'opérations effectuées par l'algorithme est de l'ordre de n4, car le produit de deux matrices carrées n × n demande n3 opérations et l'on peut avoir à effectuer n produits de matrices. La recherche du meilleur algorithme possible pour le calcul du produit de deux matrices a été très intense ces dernières années. Plusieurs améliorations de l'algorithme élémentaire demandant n3 multiplications ont été successivement trouvées. Coppersmith et Winograd détiennent le record avec un algorithme en O(n2.5); mais ce résultat reste très théorique, car la programmation de leur algorithme n'est pas aisée et l'efficacité espérée n'est atteinte que pour de très grandes valeurs de n. On cherche donc à construire d'autres algorithmes, faisant intervenir des notions différentes.

Enfin, notons que, si on se limite à la recherche de l'existence d'un chemin entre deux sommets x et y donnés, on peut ne calculer qu'une seule ligne de la matrice, ce qui diminue notablement la complexité.

1.3 Fermeture transitive
La fonction existeChemin, vue précédemment, ne fait rien de plus qu'un calcul naïf de la notion suivante: la fermeture transitive d'un graphe G=(X,A) est la relation binaire transitive minimale contenant la relation A sur X. Il s'agit d'un graphe G*=(X,A*) tel que (x,y) Î A* si et seulement s'il existe un chemin f dans G d'origine x et d'extrémité y.


Figure 1.3 : Un graphe et sa fermeture transitive



Le calcul de la fermeture transitive permet donc de répondre aux questions concernant l'existence de chemins entre tout couple de sommets x et y dans G. On effectue un pré-traitement de G en calculant G*=(X,A); puis on répond en temps constant, O(1), à toute question sur l'existence de chemins entre x et y. Une autre application du calcul de la fermeture transitive est fréquente en compilation: un graphe est associé à chaque fonction d'un programme, les sommets de ce graphe représentent les variables de la fonction et un arc entre la variable a et la variable b indique que le calcul de la valeur a fait appel au calcul de la valeur de b. La fermeture transitive de ce graphe de dépendances donne alors toutes les variables intervenant dans le calcul de a.

Le calcul de (X,A*) s'effectue par itération de l'opération de base Fx(A) qui ajoute à A les arcs (y,z) tels que y est un prédécesseur de x et z un de ses successeurs. Posons:
Fx(A) = A  È  { (y,z)  |  (y,x) Î A, (x,z) Î A }
Cette opération satisfait les deux propriétés suivantes:


Figure 1.4 : L'effet de l'opération Fx: les arcs ajoutés sont en pointillé



Proposition 4   Pour tout sommet x, on a
Fx(Fx(A))=Fx(A)
et pour tout couple de sommets (x,y) :
Fx(Fy(A))=Fy(Fx(A))

Démonstration  La première partie est très simple, on l'obtient en remarquant que (u,x) Î Fx(A) implique (u,x) Î A et que (x,v) Î Fx(A) implique (x,v) Î A .

Pour la seconde partie, il suffit de vérifier que si (u,v) appartient à Fx(Fy(A)) il appartient aussi à Fy(Fx(A)). Le résultat s'obtient ensuite par symétrie. Si (u,v) Î Fx(Fy(A)), alors ou bien (u,v) Î Fy(A), ou bien (u,x) et (x,v) Î Fy(A). Dans le premier cas, Fy(A') É Fy(A) pour tout A' É A implique (u,v) Î Fy(Fx(A)). Dans le second cas, il y a plusieurs situations à considérer suivant que (u,x) ou (x,v) appartiennent ou non à A; l'examen de chacune d'entre elles permet d'obtenir le résultat. Examinons en une à titre d'exemple, supposons que (u,x) Î A et (x,v) Ï A. Comme (x,v) Î Fy(A), on a (x,y)Î A et (y,v) Î A. Ceci implique (u,y) Î Fx (A) et (u,v) Î Fy(Fx(A)).
Proposition 5   La fermeture transitive A* est donnée par :
A* = Fx1( Fx2( ... Fxn(A)... ))

Démonstration  On se convainc facilement que A* contient l'itérée de l'action des Fxi sur A, la partie la plus complexe à prouver est que Fx1( Fx2( ... Fxn(A)... )) contient A*. Pour cela on considère un chemin joignant deux sommets x et y de G alors ce chemin s'écrit
(x,y1)(y1,y2)... (yp,y)
ainsi (x,y) Î Fy1( Fy2( ... Fyp(A)... )) les propriétés démontrées ci-dessus permettent d'ordonner les y suivant leurs numéros croissants; le fait que Fy(A') É A', pour tout A' permet ensuite de conclure.

De ces deux résultats, on obtient l'algorithme de Roy et Warshall suivant pour le calcul de la fermeture transitive d'un graphe:

static void phi (GrapheMat g, int x) {
  int n = g.m.length;
  for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
      g.m[i][j] = g.m[i][j] || g.m[i][x] && g.m[x][j];
}

static public void fermetureTransitive (GrapheMat g) {
  for (int k = 0; k < g.m.length; ++k)
    phi(g, k);
 }

Dans la fonction phi, la matrice est modifiée en cours de calcul; grâce à la proposition 4, cela ne change pas le résultat final puisque Fx = Fx2. Maintenant, si on déplie l'appel de la fonction phi (inlining en anglais), on obtient:
static public void fermetureTransitive (GrapheMat g) {
  int n = g.m.length;
  for (int k = 0; k < n; ++k)
    for (int i = 0; i < n; ++i)
      for (int j = 0; j < n; ++j)
         g.m[i][j] = g.m[i][j] || g.m[i][k] && g.m[k][j];
}

Cette fonction modifie le graphe G. Une bien meilleure solution consiste à donner comme résultat un nouveau graphe G* de la manière suivante:
static public GrapheMat fermetureTransitive (GrapheMat g) {
  GrapheMat r = copieGraphe (g);
  int n = g.m.length;
  for (int k = 0; k < n; ++k)
    for (int i = 0; i < n; ++i)
      for (int j = 0; j < n; ++j)
         r.m[i][j] = r.m[i][j] || r.m[i][k] && r.m[k][j];
  return r;
}

GrapheMat copieGraphe (GrapheMat g) {
  int n = g.m.length;
  GrapheMat r = new GrapheMat (n);
  for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
      r.m[i][j] = g.m[i][j];
  return r;
}

L'algorithme ci-dessus effectue un nombre d'opérations que l'on peut majorer par n3, chaque exécution de la fonction phi pouvant nécessiter n2 opérations; cet algorithme est donc meilleur que le calcul des puissances successives de la matrice d'adjacence.

Finalement, on remarquera que la fermeture transitive calculée dans cette section ne contient pas la relation identité. Elle ne contient pas les chemins de longueur nulle joignant tout sommet x à lui-même. Pour calculer cette fermeture transitive au sens large, on modifie très simplement l'algorithme de Warshall en ajoutant la matrice identité au résultat.

1.4 Listes de successeurs

La matrice d'adjacence peut être très creuse, une façon plus compacte de représenter un graphe consiste à associer à chaque sommet x la liste de ses successeurs. On utilise un tableau de listes que l'on note succ; on suppose que les sommets sont numérotés de 0 à n-1; la quantité succ[x] désigne la liste des successeurs de x. Cette représentation est utile pour obtenir tous les successeurs d'un sommet x. Elle permet leur accès en un nombre d'opérations égal au nombre d'éléments de cet ensemble et non pas au nombre total de sommets comme c'est le cas dans la matrice d'adjacence, ce qui peut faire une différence sensible quand n est grand. Pour un graphe G=(X,A), la place prise en mémoire est en O(|X|+|A|) au lieu d'être en O(|X|2) pour la représentation matricielle. Dans le cas du graphe de la figure 1.2, la représentation se trouve à présent sur la figure 1.5.

Une nouvelle classe Graphe correspond à la représentation par listes de successeurs:

   
   
succ[0]   =   á 1, 2ñ
succ[1]   =   á 3, 2, 5ñ
succ[2]   =   á 5ñ
succ[3]   =   á 4ñ
succ[4]   =   á 1ñ
succ[5]   =   á 3ñ
     

Figure 1.5 : Représentation des graphes par listes de successeurs


class Graphe {
  Liste[ ] succ;
  Graphe (int n) { succ = new int[n]; }
}

class Liste {
  int val;
  Liste suivant;
  Liste (int x, Liste ls) { val = x; suivant = ls; }
}

La déclaration de la classe Liste est la définition classique des listes d'entiers. Le parcours de la liste des successeurs y d'un sommet x s'effectue à l'aide de la suite des instructions suivantes. On retrouvera cette suite d'instructions comme brique de base de beaucoup de constructions d'algorithmes sur les graphes:
for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    Traitement de y
}

que l'on peut résumer en langue naturelle de la manière suivante:
Pour tout sommet y, successeur du sommet x dans G, faire {
    Traitement de y
}

On peut passer de la représentation d'un graphe par une matrice d'adjacence à la représentation par listes de successeurs en définissant dans la classe Graphe le constructeur suivant:
Graphe (GrapheMat g) {
  int n = g.m.length;
  succ = new Liste[n];
  for (int i = 0; i < n; ++i) {
    for (int j = n-1; j >= 0; --j)
      if (g.m[i][j])
        succ[i] = new Liste (j, succ[i]);
  }
}

L'ordre décroissant sur la deuxième boucle n'est qu'une légère subtilité pour présenter les listes de successeurs triées dans l'ordre croissant. Mais il n'y a aucune obligation à faire de la sorte. Réciproquement le constructeur suivant dans la classe GrapheMat permet de passer de la représentation par listes de successeurs à la représentation matricielle.
GrapheMat (Graphe g) {
  int n = g.succ.length;
  m = new boolean[n][n];
  for (int x = 0; x < n; ++x) {
    for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
      int y = ls.val;
      m[x][y] = true;
    }
  }
}

1.5 Graphes et arborescences
Définition 6   Une arborescence (X,A,r) de racine r est un graphe (X,A) où r est un élément de X tel que pour tout sommet x il existe un unique chemin d'origine r et d'extrémité x. Soit,
" x    $ !    y0, y1 ,... ,yp
tels que:
y0=r,   yp=x,    " i,    0£ i < p    (yi,yi+1 ) Î A

L'entier p est appelé la profondeur du sommet x dans l'arborescence. On montre facilement que, dans une arborescence, la racine r n'admet pas de prédécesseur et que tout sommet y, différent de r, admet un seul prédécesseur. Ceci implique:
|A|=|X|-1

La différence entre une arborescence et un arbre est mineure. Dans un arbre, les fils d'un sommet sont ordonnés (on distingue le fils gauche du fils droit). Tel n'est pas le cas dans une arborescence. Les arborescences servent depuis longtemps pour représenter des arbres généalogiques. Le vocabulaire utilisé pour les arborescences emprunte beaucoup de termes relevant des relations familiales.

L'unique prédécesseur d'un sommet (différent de r) est appelé son père, l'ensemble y0,y1 ,... yp (p ³ 0), formant le chemin de r=y0 à x = yp, est appelé l'ensemble des ancêtres de x, les successeurs de x sont aussi appelés ses fils. L'ensemble des sommets extrémités d'un chemin d'origine x est l'ensemble des descendants de x; il constitue une arborescence de racine x, celle-ci est l'union de { x } et des arborescences formées des descendants des fils de x. Pour des raisons de commodité d'écriture qui apparaîtront dans la suite, nous adoptons la convention que tout sommet x est à la fois ancêtre et descendant de lui-même. Nous représenterons une arborescence par le vecteur pere, qui à chaque sommet différent de la racine associe son père. Par convention, la racine sera le père d'elle-même.


Figure 1.6 : Une arborescence et son vecteur pere


La transformation d'un arborescence vue comme un graphe avec ses listes de successeurs en une arborescence représenté par le vecteur pere s'exprime simplement par le constructeur suivant:
class Arborescence {
  int[ ] pere;

  Arborescence (int n) { pere = new int[n]; }

  Arborescence (Graphe g, int r) {
    int n = g.succ.length;
    pere = new int[n];
    pere[r] = r;
    for (int x = 0; x < n; ++x)
      for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
        int y = ls.val;
        pere[y] = x;
      }
  }
}

Dans la suite, on suppose que l'ensemble des sommets X est l'ensemble des entiers compris entre 0 et n-1, une arborescence est dite préfixe si, pour tout sommet i, l'ensemble des descendants de i est un intervalle de l'ensemble des entiers dont le plus petit élément est i.


Figure 1.7 : Une arborescence préfixe




Figure 1.8 : Emboitement des descendants dans une arborescence préfixe


Dans une arborescence préfixe, les intervalles de descendants s'emboîtent les uns dans les autres comme des systèmes de parenthèses; ainsi, si y n'est pas un descendant de x, ni x un descendant de y, les descendants de x et de y forment des intervalles disjoints. En revanche, si x est un ancêtre de y, l'intervalle des descendants de y est inclus dans celui des descendants de x.
Proposition 7   Pour toute arborescence (X,A,r), il existe une re-numérotation des éléments de X qui la rend préfixe.

Démonstration  Pour trouver cette numérotation, on applique l'algorithme récursif suivant: La démonstration que la numérotation obtenue est bien préfixe se fait par récurrence sur le nombre de sommets de l'arborescence.

La re-numérotation préfixe d'une arborescence ne fait rien de plus que le parcours d'un arbre en ordre préfixe. On en déduit la construction d'une arborescence préfixe à partir de la représentation d'une arborescence vue comme un graphe avec ses listes de successeurs, comme suit:
int i = -1;

static int[ ] numPrefixe (Graphe g, int r) {
  int n = g.succ.length;
  int[ ] num = new int[n];
  numPrefixe1 (g, r, num);
  return num;
}

static void numPrefixe1 (Graphe g, int x, int[ ] num, int i) {
  numero[x] = ++i;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    numPrefixe1 (g, y, num);
  }
}

static void appliquerNum (int[ ] pere, int[ ] num) {
  int n = pere.length;
  for (int i = 0; i < n; ++i)
    pere[num[i]] = num[pere[i]];
}

static Arborescence arboPrefixe (Graphe g, int r) {
  Arborescence a = new Arborescence (g, r);
  appliquerNum (a.pere, numPrefixe(g, r));
  return a;
}

Exercice 1   Ecrire la fonction numPrefixe sans utiliser la variable globale i, c'est-à-dire en transformant numOrdre en variable locale.

Exercice 2   Construire directement l'arborescence préfixe sans utiliser le constructeur Arborescence.
1.6 Arborescences de Trémaux

Si les arbres peuvent être considérés comme un cas particulier des graphes, on associe souvent des arborescences ou arbres de recouvrement (spanning trees en anglais) à des parcours de graphes quelconques. En effet, pour parcourir tous les sommets d'un graphe, à la différence des arbres il faut éviter de boucler, notamment dans le cas des graphes cycliques.

En fait, le parcours de graphe le plus populaire a été mis au point par un ingénieur du 19ème siècle, Trémaux, dont les travaux sont cités dans un des premiers livres sur les graphes dû à Sainte Lagüe. Son but était de résoudre le problème de la sortie d'un labyrinthe. Depuis l'avènement de l'informatique, nombreux sont ceux qui ont redécouvert l'algorithme de Trémaux. Certains en ont donné une version bien plus précise et ont montré qu'il pouvait servir à résoudre de façon très astucieuse beaucoup de problèmes algorithmiques sur les graphes. Il est maintenant connu sous l'appellation de depth-first search (parcours en profondeur), nom que lui a donné un de ses brillants promoteurs: R. E. Tarjan. Ce dernier a découvert, entre autres, le très efficace algorithme de recherche des composantes fortement connexes que nous décrirons à la fin de ce chapitre. L'algorithme consiste à démarrer d'un sommet et à avancer dans le graphe en ne repassant pas deux fois par le même sommet. Lorsque l'on est bloqué, on revient sur ses pas jusqu'à pouvoir repartir vers un sommet non visité. Cette opération de retour sur ses pas est très élégamment prise en charge par l'écriture d'une fonction récursive. Trémaux qui n'avait pas cette possibilité à l'époque utilisait un fil d'Ariane lui permettant de se souvenir par où il était arrivé à cet endroit dans le labyrinthe. Le programme récursif suivant construit une arborescence (dite de Trémaux) pour un graphe g quelconque à partir d'un de ses sommets x:


Figure 1.9 : Exécution de l'algorithme de Trémaux


static Arborescence arboTremaux (Graphe g, int x) {
  int n = g.succ.length;
  Arborescence a = new Arborescence (n);
  for (int i = 0; i < n; ++i) a.pere[i] = -1;
  a.pere[x] = x;
  tremaux(g, x, a);
  return a;
}

static void tremaux (Graphe g, int x, Arborescence a) {
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    if (a.pere[y] == -1) {
      a.pere[y] = x;
      tremaux(g, y, a);
    }
  }
}

Le calcul effectif de l'arborescence de Trémaux de racine x s'effectue en initialisant à la valeur -1 pour signaler que le sommet correspondant n'a pas encore été visité. La figure 1.9 explique l'exécution de l'algorithme sur un exemple, les appels de la fonction sont dans l'ordre:
tremaux (g, 0, a)
    tremaux (g, 1, a)
    tremaux (g, 2, a)
        tremaux (g, 5, a)
        tremaux (g, 4, a)
    tremaux (g, 3, a)

Une version non récursive de cet algorithme est obtenue en suivant le principe général qui associe à tout programme récursif un programme itératif manipulant une pile avec les primitives associées: Pile.ajouter, Pile.supprimer, Pile.estVide pour ajouter un élément au sommet de la pile, pour retirer le sommet de la pile tout en le renvoyant comme résultat, pour tester si la pile est vide.

static Arborescence arboTremauxPile (Graphe g, int x) {
  int n = g.succ.length;
  Arborescence a = new Arborescence (n);
  for (int i = 0; i < n; ++i) a.pere[i] = -1;
  Pile p = new Pile(n);
  a.pere[x] = x;
  Pile.ajouter (p, x);
  while ( !Pile.estVide(p) ) {
    x = Pile.supprimer (p);
    for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
      int y = ls.val;
      if (a.pere[y] == -1) {
        a.pere[y] = x;
        Pile.ajouter (p, y);
      }
    }
  }
  return a;
}

Le parcours n'est pas tout à fait le même que dans le programme récursif, puisque l'ordre de parcours des successeurs d'un même sommet se fait dans l'ordre de la liste des successeurs dans la version récursive, alors qu'il se fait dans l'ordre inverse dans la version itérative. Mais les deux parcours correspondent bien à des arborescences de Trémaux, puisqu'ils s'effectuent bien en privilégiant la profondeur.

L'ensemble des sommets atteignables à partir du sommet x est formé des sommets tels que pere[y] est différent de -1 à la fin de l'algorithme. On a donc un algorithme qui répond à la question existeChemin(g, x, y) examinée plus haut avec un nombre d'opérations qui est de l'ordre du nombre d'arcs du graphe (lequel est inférieur à n2), ce qui est bien meilleur qu'avec l'algorithme utilisant des matrices d'adjacence.

L'ensemble des arcs du graphe G=(X,A) qui ne sont pas dans l'arborescence de Trémaux (Y,T, x) est divisé en quatre sous-ensembles:
  1. les arcs dont l'origine n'est pas dans Y, ce sont les arcs issus d'un sommet qui n'est pas atteignable à partir de x;

  2. les arcs de descente, il s'agit des arcs de la forme (y,z) où z est un descendant de y dans (Y,T,x), mais n'est pas un de ses successeurs dans cette arborescence;

  3. les arcs de retour, il s'agit des arcs de la forme (y,z) où z est un ancêtre de y dans (Y,T,x);

  4. les arcs transverses, il s'agit des arcs de la forme (y,z) où z n'est pas un ancêtre, ni un descendant de y dans (Y,T,x).
On remarquera que, si (y,z) est un arc transverse, on aura rencontré z avant y dans l'algorithme de Trémaux.


Figure 1.10 : Les arcs obtenus par Trémaux


Sur le graphe de la figure 1.10, les différentes sortes d'arcs y sont représentés par des lignes particulières. Les arcs de l'arborescence sont en traits gras; les arcs dont l'origine n'est pas dans Y sont dessinés en trait simple; les arcs de descente, de retour ou transverses sont en tiretés et munis d'une étiquette permettant de les reconnaître, D, R ou Tr. Les sommets ont été numérotés suivant l'ordre dans lequel on les rencontre par l'algorithme de Trémaux. Ainsi, les arcs de l'arborescence et les arcs de descente vont d'un sommet à un sommet d'étiquette plus élevée et c'est l'inverse pour les arcs de retour ou transverses.

1.7 Arborescences des plus courts chemins.

Une autre arborescence souvent associée à un graphe quelconque est l'arborescence des plus courts chemins. Elle correspond à un parcours en largeur (breadth-first search en anglais). On parcourt le graphe à partir d'un sommet en émettant une onde à partir de ce sommet et en rencontrant en priorité les sommets à égales distances de ce sommet.
Définition 8   Dans un graphe G=(X,A), pour chaque sommet x, une arborescence des plus courts chemins de racine x est une arborescence (Y,B,x) telle que:


Figure 1.11 : Une arborescence des plus courts chemins de racine 10


Cette arborescence existe bien puisque, si a1,a2,..., ap est un plus court chemin entre org(a1) et ext(ap), alors le chemin a1,a2,..., ai est aussi un plus court chemin entre org(a1) et ext(ai) pour tout i vérifiant 1£ i £ p.
Théorème 9   Pour tout graphe G=(X,A) et tout sommet x de G, il existe une arborescence des plus courts chemins de racine x.

Démonstration  On considère la suite d'ensembles de sommets construite de la façon suivante: D'autre part pour chaque Yi (i >0), on construit l'ensemble d'arcs Bi contenant pour chaque y Î Yi un arc ayant comme extrémité y et dont l'origine est dans Yi-1. On pose ensuite: Y = ÈYi, B = ÈBi. Le graphe (Y ,B) est par construction une arborescence. C'est une arborescence des plus courts chemins grâce à la remarque ci-dessus.

La figure 1.11 donne un exemple de graphe et une arborescence des plus courts chemins de racine 10, celle-ci est représentée en traits gras, les ensembles Yi et Bi sont les suivants:
Y0 = { 10 }  
Y1 = { 7,11 } B1 = { (10,7), (10,11) }
Y2 = { 3,9,8 } B2 = { (7,3), (7,9), (11,8) }
Y3 = { 5,6 } B3 = { (3,5), (8,6) }





A nouveau, la preuve de ce théorème se transforme très simplement en un algorithme de construction de l'arborescence (Y,B). Cet algorithme est souvent appelé algorithme de parcours en largeur. Nous le décrivons ci dessous, il utilise une file d'attente avec les primitives associées: FIFO.ajouter, FIFO.supprimer, FIFO.estVide pour ajouter un élément en bout de file, pour retirer le premier élément dans la file tout en le renvoyant comme résultat, pour tester si la file est vide. La file gère les ensembles Yi. On ajoute les éléments des Yi successivement dans la file, qui contient donc les Yi les uns à la suite des autres. La vérification de ce qu'un sommet n'appartient pas à Èk=1,i Yi se fait en testant si pere[y] ne vaut pas -1.
static Arborescence arboPlusCourt (Graphe g, int x) {
  int n = g.succ.length;
  Arborescence a = new Arborescence (n);
  for (int i = 0; i < n; ++i) a.pere[i] = -1;
  FIFO f = new FIFO(n);
  a.pere[x] = x;
  FIFO.ajouter (f, x);
  while ( !FIFO.estVide(f) ) {
    x = FIFO.supprimer (f);
    for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
      int y = ls.val;
      if (a.pere[y] == -1) {
        a.pere[y] = x;
        FIFO.ajouter (f, y);
      }
    }
  }
  return a;
}

On remarque que ce programme est le même que le programme itératif pour calculer une arborescence de Trémaux, en remplaçant une pile par une file d'attente.

1.8 Parcours de graphes

Nous avons considéré les parcours de graphes en profondeur et en largeur, pour traverser un graphe sans boucler, ni passer plus d'une fois par chaque sommet. A nouveau, comme la structure d'arbre (ou d'arborescence) est un cas particulier de la structure de graphe, on peut se demander quels sont, sur les graphes, les équivalents des parcours préfixes ou postfixes sur les arbres.

Au lieu de renvoyer une arborescence, nous considérons une nouvelle version du programme tremaux qui retourne une numérotation des sommets qui donne le numéro d'ordre de chaque sommet dans l'ordre où on l'a rencontré. Pour cela, on colorie les sommets traditionnellement avec trois couleurs: blanc pour les sommets non encore explorés, gris pour un sommet partiellement traité, noir pour un sommet complètement traité.
final static int BLANC = 0, GRIS = 1, NOIR = 2;
int numOrdre = -1;

static int[ ] tremauxPrefixe (Graphe g) {
  int n = g.succ.length;
  int[ ] couleur = new int[n], num = new int[n];
  for (int x = 0; x < n; ++x) couleur[x] = BLANC;
  for (int x = 0; x < n; ++x)
    if (couleur[x] == BLANC)
      tremauxPref (g, x, couleur, num);
  return num;
}

static void tremauxPref (Graphe g, int x, int[ ] couleur, int[ ] num) {
  couleur[x] = GRIS;
  num[x] = ++numOrdre;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    if (couleur[y] == BLANC)
      tremauxPref (g, y, couleur, num);
  }
  couleur[x] = NOIR;
}

Remarquons que, si la coloration en gris se faisait avant l'appel à tremauxPref, cela ne ferait aucune différence, puisque le parcours en profondeur traite immédiatement tout nouveau sommet rencontré.

On obtient une numérotation postfixe pour un parcours en profondeur en changeant simplement l'emplacement où se fait l'affectation du numéro de chaque sommet.
static int[ ] tremauxPostfixe (Graphe g) {
  int n = g.succ.length;
  int[ ] couleur = new int[n], num = new int[n];
  for (int x = 0; x < n; ++x) couleur[x] = BLANC;
  for (int x = 0; x < n; ++x)
    if (couleur[x] == BLANC)
      tremauxPost (g, x, couleur, num);
  return num;
}

static void tremauxPost (Graphe g, int x, int[ ] couleur, int[ ] num) {
  couleur[x] = GRIS;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    if (couleur[y] == BLANC)
      tremauxPost (g, y, couleur, num);
  }
  num[x] = ++numOrdre;
  couleur[x] = NOIR;
}

Un certain nombre de propriétés sur la couleur des sommets pendant le parcours récursif peuvent être énoncées. Par exemple, dans une arborescence de Trémaux, un sommet noir ne peut jamais être l'origine d'un arc vers un sommet blanc. De la même manière, un sommet blanc extrémité d'un chemin complètement blanc issu d'un sommet gris se retrouvera descendant de ce sommet gris dans l'arbre de recouvrement; etc.
Exercice 3   Ecrire les fonctions tremauxPrefixe et tremauxPostfixe en transformant la variable globale numOrdre en variable locale.

Le parcours en largeur est aussi plus compréhensible en ne raisonnant que sur la couleur des sommets:

static int[ ] largeurPref (Graphe g, int x) {
  int n = g.succ.length, numOrdre = -1;
  int[ ] couleur = new int[n], num = new int[n];
  for (int x = 0; x < n; ++x) couleur[x] = BLANC;
  FIFO f = new FIFO(n);
  couleur[x] = GRIS;
  FIFO.ajouter (f, x);
  while ( !FIFO.estVide(f) ) {
    x = FIFO.supprimer (f);
    num[x] = ++numOrdre;
    for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
      int y = ls.val;
      if (couleur[y] == BLANC) {
        couleur[y] = GRIS;
        FIFO.ajouter (f, y);
      }
    }
    couleur[x] = NOIR;
  }
  return num;
}

Dans ce parcours en largeur, la coloration en gris d'un sommet doit se faire avant de le placer dans la file d'attente, sous peine de l'y mettre plusieurs fois, et de devoir faire intervenir la valeur du résultat num qu'il est plus sage de distinguer du contrôle du parcours.

Quant au parcours postfixe, il s'obtient en déplaçant l'instruction de numérotation d'un sommet, et en la plaçant avant la coloration en noir. Mais le résultat num serait alors le même que celui obtenu dans le parcours préfixe, puisque, dans le parcours en largeur, rien ne se passe entre le rangement dans la file des successeurs d'un sommet et le traitement du sommet en question.

Tous les parcours en profondeur ou en largeur ne passent qu'une seule fois par chaque sommet et font un nombre d'opérations proportionnel au nombre d'arcs. La complexité de tels parcours dans un graphe G=(X,A) est donc en O(|X| + |A|), qu'on écrit souvent simplement par O(X + A).

1.9 Graphes acycliques

Les graphes sans cycle (directed acyclic graphs en anglais ou plus simplement dags) interviennent souvent dans les problèmes d'ordonnancement: organisation des différents travaux sur un chantier, dépendance entre modules dans un projet de programmation, etc. Un arbre est un dag particulier; mais, dans un dag, contrairement aux arbres, on peut partager des sous-dags. Un graphe est dit acyclique s'il ne contient pas de circuit.

Un algorithme pour tester l'acyclicité d'un graphe se fait facilement par un parcours en profondeur. En effet, si un graphe contient un circuit, cela signifie que, dans un arbre de recouvrement, un sommet x est l'origine d'un arc dont l'extrémité est un ancêtre de x dans l'arbre de recouvrement, comme indiqué sur la figure 1.12 en considérant x=6 duquel part un arc vers le sommet gris 12. Cela veut dire que dans le parcours en profondeur le successeur d'un sommet gris pourra être gris. Si cela ne se produit jamais, le graphe est acyclique. D'où le programme suivant:

   

Figure 1.12 : Un graphe avec cycle et son arbre de recouvrement


static boolean acyclique (Graphe g) {
  int n = g.succ.length;
  int[ ] couleur = new int[n];
  for (int x = 0; x < n; ++x) couleur[x] = BLANC;
  for (int x = 0; x < n; ++x)
    if ( (couleur[x] == BLANC) && cycleEn (g, x, couleur) )
        return false;
  return true;
}

static boolean cycleEn (Graphe g, int x, int[ ] couleur) {
  couleur[x] = GRIS;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    if ( (couleur[y] == GRIS) ||
         (couleur[y] == BLANC) && cycleEn (g, y, couleur) )
        return true;
  }
  couleur[x] = NOIR;
  return false;
}

La complexité de cet algorithme est donc en O(X+A). On se convainc qu'on ne peut faire mieux, puisqu'il faudra bien considérer tous les arcs du graphe avant de décider de son acyclicité.

Un problème classique sur les graphes sans cycle est le tri topologique. Il consiste à organiser l'agenda d'un certain nombre de tâches données par un graphe de dépendances. Prenons le cas de la lecture d'un livre, par exemple le livre de Barendregt sur le lambda-calcul [27]. Dans ses premières pages, on voit le diagramme (assez compliqué) de la figure 1.13 décrivant l'ordre de lecture des différents chapitres. Ainsi pour lire le chapitre 16, il faut avoir lu les chapitres 4, 8 et 15. Un lecteur courageux veut lire le strict minimum pour appréhender le chapitre 21. Il faut donc qu'il transforme l'ordre partiel indiqué par les dépendances du diagramme en un ordre total déterminant la liste des chapitres nécessaires au chapitre 21. Bien sûr, ceci n'est pas possible si le graphe de dépendance contient un cycle. L'opération qui consiste à mettre ainsi en ordre les sommets d'un graphe dirigé sans cycle est appelée le tri topologique.


Figure 1.13 : Un exemple de graphe acyclique


Le tri topologique ordonne les sommets d'un dag en une suite dans laquelle l'origine de chaque arc apparaît avant son extrémité. Pour un sommet s donné, on construit une liste formée de tous les sommets origines d'un chemin d'extrémité s. Cette liste doit en plus satisfaire la condition énoncée plus haut. On applique l'algorithme de descente en profondeur d'abord (Trémaux) sur le graphe inverse. (Au lieu de considérer les successeurs succ[x] du sommet x, on considère ses prédécesseurs pred[x].) Au cours de cette recherche, quand on a fini de visiter un sommet, on le met en tête de liste. En fin de l'algorithme, on calcule l'image mirroir de la liste. Pour tester l'existence de cycles, on doit vérifier lorsqu'on rencontre un sommet déjà visité que celui-ci figure dans la liste résultat.
final static BLANC = 0, GRIS = 1, NOIR = 2;

static Liste triTopologique (Graphe g, int u) {
  int n = g.succ.length;
  int[ ] etat = new int[n];
  for (int x = 0; x < n; ++x) etat[x] = BLANC;
  return Liste.mirroir (DFS (g, u, etat, null));
}

static Liste DFS (Graphe g, int x, int[ ] etat, Liste a_faire) {
  etat[x] = GRIS;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    if (etat[y] == GRIS) throw new Error ("Le graphe a un cycle");
    if (etat[y] == BLANC) {
      etat[y] = GRIS;
      a_faire = DFS (g, y, etat, a_faire);
  } }
  etat[x] = NOIR;
  return Liste.ajouter (x, a_faire);
}

La complexité de cet algorithme est celle d'une recherche en profondeur d'abord, don en O(X+A), suivie de l'image mirroir qui ne dépasse pas O(A). Au total la complexité est en O(X+A).

1.10 Connexité dans un graphe non-orienté

Comme déjà mentionné, nous représentons les graphes non-orientés par des graphes orientés symétriques. Pour tous sommets x et y, s'il existe un arc (x,y), il existe aussi un arc (y,x). Donc, s'il existe un chemin de x à y, il existe aussi un chemin de y à x.
Définition 10   Soit G=(X,A) un graphe non-orienté. La composante connexe du sommet x est l'ensemble des sommets y reliés par un chemin à x. Un graphe est connexe s'il ne contient qu'une seule composante connexe.

L'appartenance à une même composante connexe est une relation d'équivalence. Les composantes connexes forment une partition du graphe en sous-graphes déconnectés.

Dans un graphe non-orienté, un arbre de recouvrement ne contient jamais d'arc transverse. Plus exactement, l'ensemble des arcs du graphe qui ne sont pas dans un arbre de recouvrement de racine x est divisé en trois sous-ensembles:
  1. Les arcs déconnectés, dont ni l'origine, ni l'extrémité ne sont dans l'arbre de recouvrement. Il n'existe pas de chemin de x vers l'origine ou l'extrémité de ces arcs.

  2. Les arcs de descente, il s'agit des arcs de la forme (y,z) où z est un descendant non direct de y dans l'arbre de recouvrement.

  3. Les arcs de retour, il s'agit des arcs de la forme (y,z) où z est un ancêtre de y dans l'arbre de recouvrement.
On en déduit que l'ensemble des sommets d'un arbre de recouvrement constitue exactement la composante connexe de sa racine. D'où le programme suivant pour imprimer les composantes connexes d'un graphe non-orienté G.

static void imprimerCompConnexes (Graphe g) {
  int n = g.succ.length;
  int[ ] couleur = new int[n];
  for (int x = 0; x < n; ++x) couleur[x] = BLANC;
  for (int x = 0; x < n; ++x) {
    if (couleur[y] == BLANC) {
      imprimerComp (g, x, couleur);
      System.out.println();
    }
  }
}

static void imprimerComp (Graphe g, int x, int[ ] couleur) {
  couleur[x] = GRIS;
  System.out.print (x + " ");
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val;
    if (couleur[y] == BLANC)
      imprimerComp (g, y, couleur);
  }
}

Il s'agit encore d'un parcours en profondeur. Le programme imprime les composantes connexes, ligne par ligne, en temps O(X+A). De manière identique, on peut écrire un programme de sortie d'un labyrinthe, puisqu'il suffit de tester si la sortie s est dans la composante connexe de l'entrée x. Nous voulons alors retourner un chemin de x vers s, chemin que nous représentons par la liste des sommets par lesquels il passe.

static Liste sortieDeLabyrinthe (Graphe g, int x, int s) {
  int n = g.succ.length;
  int[ ] couleur = new int[n];
  for (int x = 0; x < n; ++x) couleur[x] = BLANC;
  return chemin (g, x, s, couleur);
}

static void chemin (Graphe g, int x, int s, int[ ] couleur) {
  couleur[x] = GRIS;
  if (x == s)
    return new Liste (s, null);
  else {
    for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
      int y = ls.val;
      if (couleur[y] == BLANC)
        Liste ch = chemin (g, y, s, couleur);
        if (ch != null)
          return new Liste (x, ch);
    }
  }
  return null;
}

Exercice 4   Ecrire un programme qui imprime tous les chemins simples (c'est à dire ne passant pas deux fois par un même sommet) de l'entrée vers la sortie d'un labyrinthe.

Exercice 5   Ecrire un programme qui calcule un chemin le plus court reliant l'entrée à la sortie du labyrinthe.

1.11 Biconnexité dans un graphe non-orienté

Dans une composante connexe, il existe toujours un chemin pour relier deux sommets. Dans une composante biconnexe, il doit en exister toujours deux. Ce problème se pose par exemple quand on conçoit un plan de circulation; on veut éviter les endroits névralgiques, obligatoires pour se rendre d'un endroit à un autre. De la même manière, dans un réseau informatique, on évite les sites qui déconnectent le réseau quand ils tombent en panne.
Définition 11   Soit G=(X,A) un graphe non-orienté. Un sommet x est un point d'articulation si la suppression de x déconnecte deux sommets de G.


Figure 1.14 : Points d'articulation dans un graphe non-orienté


Dans le graphe de la figure 1.14 (à gauche), les points d'articulation sont indiqués en grisé. La suppression du sommet 3 déconnecte le sommet 9; de même pour le sommet 2 qui déconnecte l'ensemble {10, 5}; et pour 10 qui déconnecte 5. Tarjan a trouvé une solution remarquable pour calculer les points d'articulation dans un graphe. Sa solution ne fait intervenir qu'un parcours en profondeur et la numérotation préfixe correspondante. En effet, caractérisons les points d'articulations sur un arbre de recouvrement. Dans l'arbre de recouvrement, un point d'articulation est un sommet x possédant un fils dont tous les descendants ne sont pas des origines d'arcs de retour vers des ancêtres stricts de x dans l'arbre. Par exemple, sur l'arbre de recouvrement de la figure 1.14 (à droite), le sommet 3 est un point d'articulation, puisque son fils 9 ne possède qu'un arc de retour vers 3 dans l'arbre de recouvrement. Mais, le sommet 12 n'est pas un point d'articulation, puisque son unique fils 3 a comme descendant 6 dont 2 est un successeur. Or 2 est un ancêtre strict de 12 dans l'arbre de recouvrement. Intuitivement, on voit bien que, si on enlève le sommet 3, on déconnecte le sous-arbre 9, alors qu'on ne déconnecte rien si on enlève le sommet 12.

Avec ce seul critère, la racine d'un arbre de recouvrement serait toujours un point d'articulation. Il faut donc faire un cas particulier pour décider si la racine est un point d'articulation. Le critère est alors simple: la racine est un point d'articulation si elle contient plusieurs fils dans l'arbre de recouvrement. Sur la figure 1.14, c'est bien le cas puisque 4 et 10 sont deux fils de 2.

Définition 12   Soit G=(X,A) un graphe, x un sommet et (Y,T,x) une arborescence de Trémaux induisant une numérotation préfixe num des sommets. Le point d'attache at(y) d'un sommet y de Y est le sommet de plus petit numéro, extrémité d'un chemin de G d'origine y et contenant au plus un arc (u,v) tel que num[u] > num[v].

Cela signifie qu'un point d'attache est un sommet atteignable par un chemin ne comportant au plus qu'un arc transverse ou de retour, puisque dans le cas des graphes non-orientés, les arcs transverses n'existent pas. Sur la figure 1.14, pour chaque sommet y, son numéro num[y] dans l'ordre préfixe est placé au-dessus de y, le numéro de son point d'attache num[at(y)] est placé à droite. Les points d'attache se calculent (dans un graphe orienté ou pas) grâce à la remarque suivante:
Proposition 13   Le point d'attache at(y) du sommet y est le sommet de plus petit numéro parmi les sommets suivants:

Pour détecter les points d'articulation, il reste donc à réaliser les deux types de tests pour un sommet ordinaire de l'arbre et pour la racine. Un peu d'arithmétique sur la numérotation préfixe effectue le premier test. Un sommet x, différent de la racine, sera un point d'articulation si et seulement s'il possède un fils y tel que:
num[x] £ num[at(y)]

On en déduit l'algorithme suivant pour détecter les points d'articulation. Le résultat est rangé dans un tableau de booléens articulation indiquant, pour chaque sommet x, s'il est un point d'articulation.
static int numOrdre; static int[ ] num;

static boolean[ ] trouverArticulations (Graphe g) {
  int n = g.succ.length;
  id = -1; num = new int[n];
  boolean[ ] articulation = new boolean[n];
  for (int x = 0; x < n; ++x) num[x] = -1;
  for (int x = 0; x < n; ++x)
    if (num[x] == -1) {
      int r = attache1(g, x, articulation);
    }
  return articulation;
}

static int attache (Graphe g, int x, boolean[ ] articulation) {
  int min = num[x] = ++numOrdre;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val; int m;
    if (num[y] == -1) {
      m = attache (g, y, articulation);
      if (m >= num[x])
        articulation[x] = true;
    } else
      m = num[y];
    min = Math.min (min, m);
  }
  return min;
}

La fonction attache1 est pratiquement identique à attache sauf que, pour la racine, il faut compter le nombre de ses fils dans l'arbre de recouvrement.
static int attache1 (Graphe g, int x, boolean[ ] articulation) {
  int min = num[x] = ++numOrdre;
  int nfils = 0;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val; int m;
    if (num[y] == -1) {
      ++nfils;
      m = attache (g, y, articulation);
      if (m >= num[x])
        articulation[x] = true;
    } else m = num[y];
    min = Math.min (min, m);
  }
  articulation[x] = nfils > 1;
  return min;
}

On peut déplier l'appel de attache1 dans trouverArticulations:
static boolean[ ] trouverArticulations (Graphe g) {
  int n = g.succ.length;
  id = -1; num = new int[n];
  boolean[ ] articulation = new boolean[n];
  for (int x = 0; x < n; ++x) num[x] = -1;
  for (int x = 0; x < n; ++x)
    if (num[x] == -1) {
      num[x] = ++numOrdre;
      int nfils = 0;
      for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
        int y = ls.val;
        if (num[y] == -1) {
          ++nfils;
          int m = attache (g, y, articulation);
        }
      }
      articulation[x] = nfils > 1;
    }
  return articulation;
}

La détection des points d'articulation se fait donc avec un simple parcours en profondeur. Sa complexité est donc en en O(X+A). Il reste à calculer les composantes bi-connexes, ce qui est un petit peu plus délicat, mais réalisable avec la même complexité.
Exercice 6   Donner une défintion précise de la notion de composantes biconnexes, et trouver un algorithme pour les calculer.

1.12 Composantes fortement connexes


Dans un graphe orienté, la notion de connexité n'est pas aussi simple que dans un graphe non-orienté, puisque deux sommets x et y peuvent être connectés par un chemin de x à y, sans qu'il existe un chemin de y à x.
Définition 14   Soit G=(X,A) un graphe. Notons ºG la relation d'équivalence suivante entre sommets: x ºG y si x=y ou s'il existe un chemin joignant x à y et un chemin joignant y à x. Les classes d'équivalences définies par ºG sont les composantes fortement connexes de G.

La relation ºG est clairement une relation d'équivalence. La symétrie et la réflexivité sont évidentes. La transitivité résulte de ce que l'on peut concaténer un chemin entre x et y et un chemin entre y et z pour obtenir un chemin entre x et z. Sur la figure 1.15, le graphe comporte 5 composantes fortement connexes, trois ne contiennent qu'un seul sommet, une est constituée d'un triangle et la dernière comporte 9 sommets. Lorsque la relation ºG n'a qu'une seule classe, le graphe est dit fortement connexe. Un exemple de graphe fortement connexe est celui des voies de circulation d'une ville en tenant compte des sens uniques.


Figure 1.15 : Composantes fortement connexes du graphe de la figure 1.10


Un algorithme de recherche des composantes fortement connexes débute nécessairement par un parcours à partir d'un sommet x, les sommets qui n'appartiennent pas à l'arborescence ainsi construite ne sont certainement pas dans la composante fortement connexe de x, mais la réciproque n'est pas vraie: un sommet y qui est dans l'arborescence issue de x n'est pas nécessairement dans sa composante fortement connexe car il se peut qu'il n'y ait pas de chemin allant de y à x.

Une manière simple de procéder pour le calcul de ces composantes consiste à itérer l'algorithme suivant pour chaque sommet x dont la composante n'a pas encore été construite: Cette manière de procéder est peu efficace lorsque le graphe possède de nombreuses composantes fortement connexes, car on peut être amené à parcourir tout le graphe autant de fois qu'il y a de composantes. Nous allons voir que la construction de l'arborescence de Trémaux issue de x va permettre de calculer toutes les composantes connexes des sommets descendants de x en un nombre d'opérations proportionnel au nombre d'arcs du graphe.

Les liens entre arborescence de Trémaux (Y,T,x) et les composantes fortement connexes sont dus à la proposition suivante.


Figure 1.16 : Un exemple de sous-arborescence



Définition 15   Une sous-arborescence (Y',T',r') de l'arborescence (Y,T,r) est une arborescence telle que Y' Ì Y et T' Ì T.

Ainsi tout élément de Y' est extrémité d'un chemin d'origine r' et ne contenant que des arcs de T'.
Proposition 16   Soit G=(X,A) un graphe. Soient x Î X, et (Y,T,x) une arborescence de Trémaux. Pour tout sommet u de Y, la composante fortement connexe contenant u est une sous-arborescence de (Y,T,x).

Démonstration  Notons C(u) la composante fortement connexe C(u) de u (contenant u). Cette proposition contient en fait deux conclusions; d'une part elle assure l'existence d'un sommet u0 dans C(u) tel que tous les éléments de C(u) sont des descendants de u0 dans (Y,T,x), d'autre part elle affirme que pour tout v de C(u) tous les sommets du chemin de (Y,T,x) joignant u0 à v sont dans C(u).

La deuxième affirmation est simple à obtenir car, dans un graphe, tout sommet situé sur un chemin joignant deux sommets appartenant à la même composante fortement connexe est aussi dans cette composante. Pour prouver la première assertion, prenons pour u0 le sommet de plus petit numéro de C(u), et montrons que tout sommet v de C(u) est un descendant de u0 dans (Y,T,x). Supposons le contraire. Comme v est dans la même composante que u0, il existe un chemin f d'origine u0 et d'extrémité v. Soit w le premier sommet de f qui n'est pas un descendant de u0 dans (Y,T,x) et soit w' le sommet qui précède w dans f. L'arc (w',w) n'est pas un arc de T, ni un arc de descente, c'est donc un arc de retour ou un arc transverse et on a
num[u0] £ num[w] £ num[w']
L'arborescence (Y,T) étant préfixe on en déduit que w est descendant de u0 d'où la contradiction cherchée.

On remarquera qu'un chemin qui conduit d'un sommet y à son point d'attache est ou bien vide (le point d'attache est alors y lui même), ou bien contient une suite d'arcs de T suivis par un arc de retour ou un arc transverse. En effet, une succession d'arcs de T partant de y conduit à un sommet de numéro plus grand que y, d'autre part les arcs de descente ne sont pas utiles dans la recherche du point d'attache, ils peuvent être remplacés par des chemins formés d'arcs de T.


Figure 1.17 : Les points d'attaches des sommets d'un graphe


Dans la figure 1.17, on a calculé les points d'attaches des sommets d'un graphe, ceux-ci ont été numérotés dans l'ordre où on les rencontre dans l'algorithme de Trémaux (on suppose pour simplifier que x = num[x] pour tout x); le point d'attache est indiqué en petit caractère à droite du sommet en question.

Comme dans le cas de la biconnexité, le calcul des composantes fortement connexes à l'aide des at(u) se fait par un peu d'arithmétique sur la numérotation préfixe et les points d'attache. C'est une conséquence du théorème suivant:

Théorème 17   Si u est un sommet de Y satisfaisant: Alors, l'ensemble desc(u) des descendants de u dans (Y,T,x) forme une composante fortement connexe de G.

Démonstration  Montrons d'abord que tout sommet de desc(u) appartient à C(u). Soit v un sommet de desc(u), il est extrémité d'un chemin d'origine u, prouvons que u est aussi extrémité d'un chemin d'origine v. Si tel n'est pas le cas, on peut supposer que v est le plus petit sommet de desc(u) à partir duquel on ne peut atteindre u, soit f le chemin joignant v à at(v), le chemin obtenu en concaténant f à un chemin de (Y,T,x) d'origine u et d'extrémité v contient au plus un arc de retour ou transverse ainsi:
u=at(u) £ at(v) < v

Comme (Y,T,x) est préfixe, at(v) appartient à desc(u) et d'après l'hypothèse de minimalité il existe un chemin d'origine at(v) et d'extrémité u qui concaténé à f fournit la contradiction cherchée.

Il reste à montrer que tout sommet w de C(u) appartient aussi à desc(u). Un tel sommet est extrémité d'un chemin g d'origine u, nous allons voir que tout arc dont l'origine est dans desc(u) a aussi son extrémité dans desc(u), ainsi tous les sommets de g sont dans desc(u) et en particulier w. Soit (v1,v2) Î A un arc tel que v1 Î desc(u), si v2 > v1, v2 est un descendant de v1 il appartient donc à desc(v); si v2 < v1 alors le chemin menant de u à v2 en passant par v1 contient exactement un arc de retour ou transverse, ainsi :
u=at(u) £ v2 < v1

et la préfixité de (Y,T,x) implique v2 Î desc(u).

Remarquons qu'il existe toujours un sommet satisfaisant les conditions du théorème. En effet, si x est la racine de (Y,T,x), on a at(x)=x. Si x satisfait (ii), l'ensemble Y en entier constitue une composante fortement connexe. Sinon il existe un descendant y de x tel que y=at(y). En répétant cet argument plusieurs fois et puisque le graphe est fini, on finit par obtenir un sommet satisfaisant les deux conditions.

La recherche des composantes fortement connexes est alors effectuée par la détermination d'un sommet u tel que u= at(u), obtention d'une composante égale à desc(u), suppression de tous les sommets de desc(u) et itération des opérations précédentes jusqu'à obtenir tout le graphe.

Sur la figure 1.17, on peut se rendre compte du procédé de calcul. Il y a 4 composantes fortement connexes, les sommets u satisfaisant u = at(u) sont au nombre de 3, il s'agit de 0, 1, 5. La première composante trouvée se compose du sommet 5 uniquement, il est supprimé et le sommet 6 devient alors tel que u = at(u). Tous ses descendants forment une composante fortement connexe {6,7,8}. Après leur suppression, le sommet 1 satisfait u = at(u) et il n'a plus de descendant satisfaisant la même relation. On trouve ainsi une nouvelle composante {1,2,3,4}. Une fois celle-ci supprimée, 0 est le seul sommet qui satisfait la relation u = at(u) d'où la composante {0,9,10,11,12,13,14,15,16}.

L'algorithme ci-dessous calcule en même temps at(u) pour tous les descendants u de x et obtient successivement toutes les composantes fortement connexes de desc(x). Il utilise le fait que la suppression des descendants de u lorsque u=at(u) ne modifie pas les calculs des at(v) en cours.

static int id = -1;

static void imprimerCompFConnexes (Graphe g) {
  int n = g.succ.length;
  int[ ] num = new int[n];
  for (int x = 0; x < n; ++x) num[x] = -1;
  for (int x = 0; x < n; ++x) {
    if (num[y] == -1)
      imprimerCompF (g, x, num);
  }
}

static int imprimerCompF (Graphe g, int x, int[ ] num) {
  num[x] = ++numOrdre; Pile.ajouter(x, p);
  int min = id;
  for (Liste ls = g.succ[x]; ls != null; ls = ls.suivant) {
    int y = ls.val; int m;
    if (num[y] == -1)
      m = imprimerCompF (g, y, num);
    else m = num[y];
    min = Math.min (min, m);
  }
  if (min == num[x]) {
    int y;
    do {
      y = Pile.supprimer(p);
      System.out.print (y + " ");
      num[y] = g.succ.length;
    } while (y != x);
    System.out.println();
  }
  return min;
}

Ce bel algorithme, dû à Tarjan, illustre la puissance de la notion de parcours en profondeur; il a une complexité en O(X+A).

1.13 Programmes en OCaml

let m =
 [| [| 1.0; 0.0; 0.0 |];
    [| 0.0; 1.0; 0.0 |];
    [| 0.0; 0.0; 1.0 |] |];;

let g =
 [| [| 0; 1; 1; 0; 0; 0 |];
    [| 0; 0; 1; 1; 0; 1 |];
    [| 0; 0; 0; 0; 0; 1 |];
    [| 0; 0; 0; 0; 1; 0 |];
    [| 0; 1; 0; 0; 0; 0 |];
    [| 0; 0; 0; 1; 0; 0 |] |];;

let nb_lignes m = Array.length m
and nb_colonnes m =
  if Array.length m = 0 then failwith "nb_colonnes"
  else Array.length m.(0);;

(* Calcule le produit des matrices a et b *)
let multiplier a b =
  if nb_colonnes a <> nb_lignes b
  then failwith "multiplier" else
  let c =
     Array.make_matrix (nb_lignes a) (nb_colonnes b) 0 in
   for i = 0 to nb_lignes a - 1 do
     for j = 0 to nb_colonnes b - 1 do
      let cij = ref 0 in
      for k = 0 to nb_colonnes a - 1 do
        cij := a.(i).(k) * b.(k).(j) + !cij;
      done;
      c.(i).(j) <- !cij
    done
  done;
  c;;



(* Calcule la somme des matrices a et b *)
let ajouter a b =
  if nb_colonnes a <> nb_lignes b
  then failwith "ajouter" else
  let c =
    Array.make_matrix (nb_lignes a) (nb_colonnes b) 0 in
  for i = 0 to nb_lignes a - 1 do
    for j = 0 to nb_colonnes b - 1 do
      c.(i).(j) <- a.(i).(j) + b.(i).(j)
    done
  done;
  c;;



(* Élève la matrice m à la puissance i *)
let rec puissance m i =
  match i with
  | 0 -> failwith "puissance"
  | 1 -> m
  | n -> multiplier m (puissance m (i - 1));;



let nombre_de_chemin_de_longueur n i j m =
  (puissance m n).(i).(j);;

let sigma i m =
  let rec pow i mp =
    match i with
    | 1 -> mp
    | n -> ajouter mp (pow (i - 1) (multiplier m mp)) in
  pow i m;;

let existe_chemin i j m =
  (sigma (nb_colonnes m) m).(i).(j) <> 0;;



let phi m x =
 for u = 0 to nb_colonnes m - 1 do
   if m.(u).(x) = 1 then
     for v = 0 to nb_colonnes m - 1 do
       if m.(x).(v) = 1 then m.(u).(v) <- 1
     done
 done;;

let fermeture_transitive m =
  let résultat =
    Array.make_matrix (nb_lignes m) (nb_colonnes m) 0 in
  for i = 0 to nb_lignes m - 1 do
    for j = 0 to nb_colonnes m - 1 do
     résultat.(i).(j) <- m.(i).(j)
    done
  done;
  for x = 0 to nb_colonnes m - 1 do
    phi résultat x done;
  résultat;;



(* Tableaux de successeurs *)

type graphe_point = (int list) array;;
let omega = -1;;

let succ_of_mat m =
  let nb_max_succ = ref 0 in
  for i = 0 to nb_lignes m - 1 do
    nb_max_succ := 0;
    for j = 0 to nb_colonnes m - 1 do
      if m.(i).(j) = 1 then
        nb_max_succ := max j !nb_max_succ
    done;
  done;
  let succ =
    Array.make_matrix (nb_lignes m) (!nb_max_succ + 1) 0 in
  let k = ref 0 in
  for i = 0 to nb_lignes m - 1 do
    k := 0;
    for j = 0 to nb_colonnes m - 1 do
      if m.(i).(j) = 1 then begin
        succ.(i).(!k) <- j;
        incr k
      end
    done;
    succ.(i).(!k) <- omega
  done;
  succ;;



(* Listes de successeurs *)
let liste_succ_of_mat m =
  let gpoint = Array.make (nb_colonnes m) [] in
  for i = 0 to nb_lignes m - 1 do
    for j = 0 to nb_colonnes m - 1 do
      if m.(i).(j) = 1
      then gpoint.(i) <- j :: gpoint.(i)
    done
  done;
  gpoint;;



let numéro = Array.make (Array.length succ) (-1);;
let num = ref (-1);;

let rec num_prefixe k = begin
  incr num;
  numéro.(k) <- !num;
  List.iter
    (function  x -> if numéro.(x) = -1 then
          num_prefixe (x))
    succ.(k)
  end;;

let numPrefixe() = begin
  Array.iter
    (function x -> if numéro.(x) = -1 then
         num_prefixe (x))
    numéro
  end;;



let num_largeur k =
  let f = file_vide() in begin
  fajouter k f;
  while not (fvide q) do
    let k = fvaleur(f) in begin
    fsupprimer f;
    incr num;
    numéro.(k) <- !num;
    List.iter
      (function  x -> if numéro.(x) = -1 then
         begin
         fajouter x f;
         numéro.(x) <-  0
         end)
      succ.(k)
    end
  done
  end;;

let numLargeur() = begin
  Array.iter
    (function x -> if numéro.(x) = -1 then
         num_largeur (x))
    numéro
  end;;



(* calcule la composante connexe
   de k et retourne son point d'attache *)
let rec comp_connexe k = begin
    incr num; numéro.(k) <- !num;
    Pile.ajouter k p;
    let min = ref !num in begin
      List.iter
        (function x ->
          let m = if numéro.(x) = -1 then
                comp_connexe (x)
            else
                numéro.(x)
            in if m < !min then
                min := m)
         succ.(k);
      if !min = numéro.(k) then
        (try while true do
            printf "%d " (Pile.sommet p);
            numéro.(Pile.sommet p) <- max_int;
            Pile.supprimer p;
            if (Pile.sommet p) = k then raise Exit
            done
        with Exit -> printf "\n");
      !min
      end
    end;;

let compConnexe() = begin
  Array.iter
    (function x -> if numéro.(x) = -1 then
         comp_connexe (x))
    numéro
  end;;







Précédent Remonter Suivant