• Aucun résultat trouvé

3.4 Approximation

3.4.7 Union-Find

Bien que Prim ait une meilleure complexité dans le cas oùm =Θ(n2), on peut pré-férer Kruskal qui se révèle extrêment efficace lorsqu’il est implémenté avec la bonne structure de données. Rappelons que dans ce dernier, on ajoute les arêtes par poids croissant, sans créer de cycles, jusqu’à former un arbre couvrant. Le coût théorique de Kruskal est dominé par le tri des m arêtes selon leurs poids, ce qui en pratique peut être réalisé très efficacement avec des routines dédiées et optimisées comme qsort(). Le reste de l’algorithme se résume à un simple parcours desm arêtes ainsi triées et à la construction de l’arbre qui, comme on va le voir, prend un temps total quasiment linéaire enn.

Nous allons détailler l’implémentation de Kruskal, en particulier la partie permet-tant de savoir si une arête forme un cycle ou pas, et donc si elle doit être ajoutée à la forêt courante.

Parenthèse.La validité de l’algorithme deKruskalse démontre par un argument d’échange, qui est souvent utilisé pour prouver l’optimalité d’un algorithme glouton.

On considère l’arbre Aconstruit par Kruskal et B un arbre de poids minimum. L’hy-pothèse est que A, B. L’argument consiste à montrer que dans ce cas on peut, à l’aide d’échanges entreAetB, améliorer la solution deB, ce qui conduit bien sûr à une contradic-tion.

Pour que l’argument fonctionne même dans le cas où les arêtes du graphe que l’on veut couvrir ont des poids égaux, on va fixer un ordre total sur les arêtes. Elles sont donc ordon-nées et ont un rang. On dira que l’arête e est « avant » e0 si le poids de e est strictement inférieur à celui dee0 ou s’il est égal mais que le rang deeest inférieur à celui dee0. Donc sans perte de généralité, on va supposer queB est un arbre de poids minimum et de rang minimum, c’est-à-dire que parmi les arbres de poids minimum,B est celui dont la somme des rangs de ses arêtes est la plus petite possible.

Soit e la première arête deA qui n’est pas dansB. Cette arête existe bien carA et B couvrent tous deux les mêmes sommets, ont le même nombre d’arêtes etA,B. Ajoutée àB, l’arête eforme un cycleC. Ce cycle n’existe pas dansA, puisque c’est un arbre. Il possède donc au moins une arêtee0<A. De pluse0,epuisqueeA. Si l’algorithme a traité l’arêtee avante0, alors dansBen supprimante0et en ajoutanteon obtient un nouvel arbre couvrant qui a un poids ou un rang inférieur, ce qui est une contradiction avec l’hypothèse faite sur

3.4. APPROXIMATION 117 B. Cependant, si l’algorithme a considéré e0 <Aavante, c’est qu’elle formait un cycle C0 dansAcomposé uniquement d’arêtes situées avante0. Le cycleC0 ne pouvant exister dans B, il doit contenir une autre arêtee00<C0 qui est dansAmais pas dansBet qui est avante0 et donc avante. Cela contredit l’hypothèse queeétait la première telle arête.

Au final on a montré queA=Bet queKruskalcalcule donc un arbre de poids minimum (qui est aussi celui de rang minimum).

Le problème général sous-jascent est de « maintenir » les composantes connexes d’un graphe qui au départ est composé de sommets isolées et qui croît progressivement par ajout d’arêtes (cf. figure3.30). Ici « maintenir » signifie qu’on souhaite pouvoir répondre à la question « quelle est la composante connexe deu? » au fur et à mesure de l’évolua-tion du graphe.

v

C0 C

u

Figure 3.30 – Maintient des composantes connexes d’un graphe à l’aide d’une forêt couvrante : on ajoute une arête seulement si elle ne forme pas de cycle.

Pour cela on va résoudre un problème assez général de structure de données qui est le suivant. Dans ce problème il y a deux types d’objects : desélémentset des ensembles.

L’objectif est de pouvoir réaliser le plus efficacement possibles les deux opérations sui-vantes (voir la figure3.31pour un exemple) :

• Fusionnerdeux ensembles donnés ;(Union)

• Trouverl’ensemble contenant un élément donné.(Find)

b a b c d e f

a c d e f

Figure 3.31 – Exemple à droite de 4 fusions d’ensembles sur 6 éléments, aboutissant aux ensembles {a, b, c, d} et {e, f}. Les ensembles sont codés, comme à gauche, par des arbres enracinés. Ils sont identifiés par leur racine.

Plusieurs forêts sont possibles pour coder ces mêmes fusions (et donc ces mêmes ensembles), comme par exemple : aybycyd exf. Donc « fu-sion » et « représentation des ensembles » sont deux choses bien distinctes.

118 CHAPITRE 3. VOYAGEUR DE COMMERCE Par rapport à notre problème de composantes connexes, les éléments sont les som-mets et les ensembles les composantes connexes. Muni d’une telle structure de données, l’algorithme de Kruskal peut se résumer ainsi :

AlgorithmeKruskal(G, ω) Entrée: Un graphe arête-valué (G, ω).

Sortie: Un arbre couvrant de poids minimum (une forêt siGn’est pas connexe).

1. InitialiserT := (V(G),∅).

2. Pour chaque arêteuv deGprise dans l’ordre croissant de leur poidsω: (a) Trouverla composanteC deu et la composanteC0 dev;

(b) siC ,C0, ajouteruvàT etFusionnerC etC0. 3. RenvoyerT.

La structure de données qui supporte ces opérations s’appelle Union-Find. Comme on va le voir, elle est particulièrement simple à mettre en œuvre et redoutablement efficace.

La structure de données représente chaque ensemble par un arbre enraciné, les nœ-uds étant les éléments de l’ensemble. L’ensemble est identifié par la racine de l’arbre.

Donc trouver l’ensemble d’un élément revient en fait à trouver la racine de l’arbre le contenant. Notez bien que l’arbre enraciné représentant un ensemble (ou une compo-sante connexe) n’a pasa prioride rapport avec l’arbreT construit parKruskal.

On code un arbre enraciné par la relation de parenté, un tableau parent[], avec la convention queparent[u]=usiuest la racine. On suppose qu’on a un total denéléments qui sont, pour simplifier, des entiers (int) que l’on peut voir comme les indices des éléments. Au départ, tout le monde est racine de son propre arbre qui comprend un seul nœud : on anéléments etnsingletons.

// Initialisation Union-Find (v1) int parent[n];

for(u=0; u<n; u++) parent[u]=u;

Pour trouver l’ensemble contenant un élément, on cherche la racine de l’arbre auquel il appartient. Pour la fusion de deux ensembles, identifiés par leur racine (disonsx et y), on fait pointer une des deux racines vers l’autre (ici ypointe vers x). Ce qui donne, en supposant pour simplifier queparent[]est une variable globale :

3.4. APPROXIMATION 119

// Union-Find (v1)

void Union(int x, int y){

parent[y]=x;

}

int Find(int u){

while(u!=parent[u]) u=parent[u];

return u;

}

x z

u v

y

Cz

z u v

Cx

Cx Cy

x

y

Figure 3.32 – À gauche, fusion des ensembles x=Find(u) et y=Find(v) avec Union(x,y), ensembles représentés par des arbres enracinés. À droite, com-posantes connexes d’un graphe obtenu par ajout d’arêtes et maintenues par la structure de données arborescente de gauche.

Attention ! Pour le problème du maintient des composantes connexes d’un graphe, l’arbre enraciné qui représente l’ensemble n’a rien à voir avec la composante connexe elle-même (qui dansKruskalse trouvent être aussi un arbre). Les arcs des arbres codant les ensembles ne correspondent pas forcément à des arêtes de la composante. Dans la figure3.32, on va fusionner la composante deu avec celle devà cause d’une arêteuv.

On obtiendra une nouvelle composante représentée par un arbre ayant un arc entre x ety, mais sans arc entreuetv.

On fait la fusion d’ensembles et pas d’éléments. Par exemple pour fusionner l’en-semble contenant u avec l’ensemble contenantv, il faut d’abord chercher leurs racines respectives, ce qui se traduit par (cf. aussi la figure3.32) :

Union(Find(u),Find(v));

En terme de complexité, Union() prend un temps constant, et Find(u) prend un temps proportionnel à la profondeur de u, donc au plus la hauteur de l’arbre. Mal-heureusement, après seulementn−1 fusions, la hauteur d’un arbre peut atteindren−1 comme dans l’exemple suivant :

120 CHAPITRE 3. VOYAGEUR DE COMMERCE

for(int u=1; u<n; u++) Union(Find(u),Find(0));

Cela aboutit à un chemin contenant tous les éléments, l’élément 0 étant une feuille dont la profondeur ne cesse d’augmenter :

n−1←n−2← · · · ←2←1←0

Plus embêtant, le temps cumulé de ces fusions est de l’ordre de n2 puisqueFind(0) à l’étapeu prend un temps proportionnel àu. Ce qui n’est pas très efficace, même si dans cet exemple on aurait pu faire mieux avecUnion(Find(0),Find(u)).[Question. Pourquoi est-ce mieux ?]On va faire beaucoup mieux grâce aux deux optimisations suivantes.

Première optimisation. Cette optimisation, dite durang, consiste à fusionner le plus petit arbre avec le plus grand (en terme de hauteur). L’idée est que si on rattache un arbre peu profond à la racine du plus profond, alors la hauteur du nouvel arbre (et donc le coût des futursFind()) ne changera pas (cf. figure3.33). Elle augmentera que si les arbres sont de même hauteur. Pour cela on ajoute donc un simple tableaurank[] per-mettant de gérer la hauteur de chacun des arbres qu’il va falloir maintenir. On mettra à jour lerank[]seulement si la hauteur doit augmenter. Pour cette première optimisation, sixest une racine,rank[x]va correspondre à la hauteur de son arbre. Pour la deuxième, il s’agira d’un simple majorant de cette hauteur, majorant qu’on appelera doncrang.

// Initialisation Union-Find (v2) int parent[n], rank[n];

for(u=0; u<n; u++) parent[u]=u, rank[u]=0;

Parenthèse.De manière générale en algorithmique, l’augmentation de espace de travail peut permettre un gain de temps, comme ici avec l’ajout du tableaurank[]ou la mémorisation utilisée en programmation dynamique discutée au chapitre 2. Cela a cependant un coût : celui de la mise à jour des informations lors de chaque opération. Et c’est nécessaire ! En effet, s’il n’y avait pas besoin de mise à jour, c’est que l’information n’était pas vraiment utile. Pour les structures de données, il y a donc un compromis entre le temps de requête (c’est-à-dire la complexité en temps des opérations supportées par la structure de données), la taille de la structure de données (par exemple le nombre de champs dans unestruct) et le temps de mise à jour (de chacun des champs) qui inévitablement va s’allonger en fonction de la taille.

// Union (v2)

void Union(int x, int y){

if(rank[x]>rank[y]) parent[y]=x; // y → x

else{ parent[x]=y; // x → y

if(rank[x]==rank[y]) rank[y]++; // mise à jour de rank[y]

} }

3.4. APPROXIMATION 121

y x

Union(x,y)

x

y

Figure 3.33 – Opération Union(x,y) avec optimisation dite du rang : c’est l’arbre le plus petit qui se raccroche au plus grand.

Notez que ce qui nous intéresse ce n’est pas la hauteur de chacun des sommets, mais seulement des arbres (ici les racines) ce qui permet une mise à jour plus rapide. La com-plexité de l’opérationUnion(), bien que légèrement plus élevée, est toujours constante.

Le gain pourFind()est cependant substantiel.

Proposition 3.5 Tout arbre de rangrpossède au moins2r éléments.

Preuve.Par induction surr. Pourr= 0, c’est évident, chaque arbre contenant au moins 1 = 20 élément. Supposons vraie la propriété pour tous les arbres de rangr. D’après le code, on obtient un arbre de rang r+ 1 seulement dans le cas où l’arbre est obtenu par la fusion de deux arbres de rangr. Le nouvel arbre contient, par hypothèse, au moins

2r+ 2r = 2r+1éléments. 2

Il est clair qu’un arbre possède au plus les n éléments. Donc si un arbre de rang r possède k éléments, alors on aura évidemment 2r 6 k6 n ce qui impliquer 6log2n.

Donc chaque arbre a un rang O(logn). Il est facile de voir, par une simple induction, que le rang de l’arbre est bien sa hauteur. Cela implique que la complexité deFind()est O(logn). C’est un gain exponentielle par rapport à la version précédente !

Deuxième optimisation. La seconde optimisation, appeléecompression de chemin, est basée sur l’observation qu’avant de faireUnion()on fait unFind()(en fait deux). Lors du Find()sur un élémentu qui a pour effet de parcourir le chemin de u vers sa racine, on en profite pour connecter directement à la racine chaque élément du chemin parcouru.

C’est comme si le chemin deuà sa racine avait été compressé en une seul arête ramenant tous les sous-arbres accrochés à ce chemin comme fils de la racine. Voir la figure3.34.

Cela ne change pas la complexité de l’opération de Find(u), il s’agit d’un parcours que l’on effectue de toute façon. Mais cela va affecter significativement la complexité des Find() ultérieurs puisque l’arbre contenant u a été fortement raccourcis. Voici le code (final) :

122 CHAPITRE 3. VOYAGEUR DE COMMERCE

x

Find(u)

u

x u

Figure3.34 – OpérationFind(u)avec compression de chemin.

// Find (v2) int Find(int u){

if(u!=parent[u]) parent[u]=Find(parent[u]);

return parent[u];

}

On pourrait éviter la récursivité et l’usage de la pile avec deux parcours : un premier pour trouver la racine et un second pour changer tous les parents. [Exercice. Écrire le code non-récursif correspondant pourFind().]

On remarque querank[]n’est pas modifié parFind()alors que la hauteur de l’arbre est susceptible de diminuer. Le rang devient un simple majorant. Ce n’est pas gênant, car ce qui compte c’est que les Find()aient un coût faible. Avec cette optimisation, il devient très difficile d’avoir une séquence deFind() couteuse, car chaque compression diminue le coût à venir de nombreux éléments. On peut montrer :

Proposition 3.6 Lorsque les deux optimisations « rang » et « compression de chemin » sont réalisées, la complexité de m opérations de fusion et/ou de recherche sur n éléments est de O(m·α(m, n))oùα(m, n)est la fonction inverse d’Ackermann29, une fois l’initialisation de la structure de données effectuée enO(n).

Ce résultat reste valable si on remplace la compression de chemin par un simple rac-coursicement où le parent de chaque sommet du chemin est ré-apparenté à son grand-père. Cela qui peut avoir un intérêt en pratique car il évite la récursion ou un double parcours. [Exercice. Donnez le code correspondant pour une telle implémentation de Find().]

On ne démontrera pas ce résultat qui est difficile à établir. On dit aussi parfois que lacomplexité amortiedes opérations de fusion et de recherche est deO(α(m, n)) dans la mesure où la somme demopérations estO(m·α(m, n)).

29. Ce résultat, ainsi que la fonction inverse Ackermann aussi définie page113, est expliquéici.

3.4. APPROXIMATION 123 On peut montrer queα(m, n)64 pour toutes valeurs réalistes dem et den, jusqu’à 22048≈10890soit beaucoup plus que le nombre de particules de l’univers estimé à 1080. Cela n’a évidemment pas de sens de vouloir allouer un tableau de taillenaussi grande.

Parenthèse.Il a été démontré dans [Tar79][Ban80][FS89][BAG01] que le termeα(m, n)est en fait nécessaire. Quelle que soit la structure de données utilisée, il est nécessaire d’accéder àΩ(n+m·α(m, n))mots mémoires pour effectuern−1opérations de fusion etmopérations de recherche dans le pire des cas. De plus, une seule de ces opérations peut nécessiter un tempsΩ(logn/log logn)dans le pire des cas.

La fonction inverse d’Ackermann apparaît aussi dans le contexte de la géométrique dis-crète, où il s’agit de déterminer dans un arrangement de nsegments de droite du plan la complexité de l’enveloppe basse, c’est-à-dire le nombre de sous-segments que verrait un ob-servateur basé sur l’axe des abscisses (voir la figure3.35à gauche).

Cette complexité intervient dans les algorithmes permettant de déterminer cette enve-loppe basse, comme par exemple les algorithmes de rendu par lancer de rayons (ray-tracing ou eye-tracing) pour des scènes composés d’objets polygonaux s’intersectant (ou non, comme sur la figure3.35à droite).

1

2