Petit lexique sur les graphes et les arbres.
La notion de graphe a été abordée en 1ère année et ses nombreuses déclinaisons s'avèrent d'une très grande richesse, à la fois comme modèles et comme outils d'étude en algorithmique. Elle fait l'objet d'une théorie à part entière, la théorie des graphes.
Dans un arc \((x,y)\), le sommet \(x\) est appelé le prédécesseur de \(y\) et \(y\) le successeur de \(x\). On note \(\Gamma\) la correspondance \((X,U,X)\) et pour tout sommet \(x\in X\) du graphe, on écrira \(\Gamma(x)\) plutôt que \(\Gamma(\{x\})\) l'ensemble des successeurs de \(x\). Un arc \((x,x)\) est appelé une boucle. Un graphe orienté hérite du vocabulaire d'un graphe d'une relation binaire, réflexif, symétrique, transitif, etc. Si l'orientation n'a pas d'importance, on remplace généralement \(U\) par un sous-ensemble de paires \(\{x,y\}\) et de singletons \(\{x\}\). On parle alors d'arête au lieu d'arc et le graphe est appelé graphe non orienté.
Dans un graphe orienté (resp. non orienté), une séquence de sommets telle que chaque couple de sommets consécutifs constitue un arc (resp. une arête) du graphe est appelée un chemin (resp. une chaîne); et si la séquence commence et se termine par le même sommet, on parle de circuit (resp. de cycle). Un chemin/chaîne qui passe exactement une fois par chaque arc/arête est appelé chemin eulérien/chaîne eulérienne. Un chemin/chaîne qui passe exactement une fois par chaque sommet est appelé chemin hamiltonien/chaîne hamiltonienne.
Un graphe est dit connexe si pour tout couple de sommets du graphe, il existe au moins une chaîne qui les relie. La longueur d'un chemin (resp. d'une chaîne) est le nombre de ses arcs (resp. de ses arêtes). Un sous-graphe d'un graphe \(G=(X,U)\) (sa restriction à un sous-ensemble \(Y\) de \(X\)) est appelé composante connexe de \(G\) s'il est connexe et maximal : aucune chaîne ne relie un sommet de \(X\setminus Y\) à un sommet de \(Y\). Un sous-graphe est appelé composante fortement connexe si pour tout couple de sommet \((x,y)\) il existe un chemin qui relie \(x\) à \(y\) (un graphe non orienté connexe est donc fortement connexe).
Un nœud qui n'a pas de fils est appelé une feuille. Un arbre enraciné est un arbre dans lequel il existe un unique nœud qui n'a pas de prédécesseur, on l'appelle la racine de l'arbre. On démontre qu'il existe une unique chaîne qui relie deux nœuds quelconques d'un arbre et que dans un arbre enraciné, tout nœud différent de la racine admet un seul père.
La profondeur d'un nœud ou la hauteur d'un nœud (tout est question de point de vue…) est la longueur du chemin qui le relie à la racine de l'arbre. La profondeur d'un arbre est la profondeur maximale d'une feuille. Le nombre de nœuds à la profondeur \(p\) est au plus \(q^p\) si l'arbre est d'arité \(q\). Un arbre de profondeur \(p\) est dit équilibré si tout nœud de profondeur \(k\) tel que \(k < p - 1\) a exactement \(q^k\) fils. Un arbre peut être muni d'une valuation de ses nœuds (et/ou de ses arcs), c'est-à-dire d'une application \(\nu:X\to{\mathbb R}\) (et/ou \(\mu:U\to{\mathbb R}\)).
Présentation de l'algorithme.
Tri d'un tas.
On cherche à nouveau à trier les valeurs d'un tableau/liste dans l'ordre croissant. Le tri par tas repose sur des échanges de valeurs des nœuds d'un arbre binaire valué. Bien que les arbres soient indispensables pour comprendre ces manipulations, ce tri n'utilise jamais de structure d'arbre pour son implantation, mais opère in situ, c'est-à-dire sur le tableau/liste fourni(e) en entrée.
L'algorithme s'appuie sur une propriété particulière de certains tableaux appelés tas. Pour comprendre ce qu'est un tas, il faut introduire la notion d'arbre partiellement ordonné (en résumé APO).
Dans les arbres ci-dessous, l'arbre binaire à gauche est partiellement ordonné mais pas équilibré, l'arbre central est équilibré mais n'est pas partiellement ordonnée, tandis que l'arbre à droite est à la fois équilibré et partiellement ordonné. Les nombres entre les arbres indiquent la profondeur des nœuds dans l'arbre. Notons que les arbres sont représentés à l'envers (racine en haut et feuilles en bas) à cause du sens de lecture qui suit la chronologie de leur construction, celle-ci commençant très souvent par la racine (pour le modèle abstrait tout au moins).
Quand on dispose d'un arbre équilibré valué, on peut le représenter très simplement avec une structure de liste/tableau et réciproquement, il suffit de ranger les valuations des nœuds dans la liste en suivant l'ordre naturel de lecture, de la gauche vers la droite et de haut en bas. Ainsi le tableau associé à l'arbre de droite de la figure ci-dessus est \begin{equation}\label{ex:tas} T = [17,12,6,10,9,4,1,2,1,5,3] \end{equation}
La propriété APO a pour conséquence immédiate que la racine de l'arbre contient la valeur la plus grande, autrement dit la première valeur du tableau est la plus grande. Cette propriété est la clef du fonctionnement du tri par tas. Ce tri ne manipule en réalité qu'une structure de tableau/liste et jamais de structure d'arbre qui ne sert qu'à comprendre les mécanismes de ce tri. Nous allons tout d'abord décrire comment l'algorithme trie un tas. Bien entendu tous les tableaux ne sont pas des tas, mais nous verrons comment transformer un tableau quelconque en tas avec l'algorithme Entasser un peu plus bas.
Le principe est simple et élégant. La plus grande valeur, \(17\) dans notre exemple, étant placé en tête du tas d'après le lemme précédent, il suffit de l'échanger avec la dernière valeur du tableau, ici \(3\), pour qu'elle soit définitivement rangée comme le fait le TriSélection. C'est ce que montre l'arbre de gauche ci-dessous pour le tas \((\ref{ex:tas})\). Après cette transposition, l'arbre n'est plus nécessairement apo (sauf si la valeur qui était dans la dernière cellule du tableau avant l'échange se trouvait être justement la seconde plus grande valeur du tableau).
Pour restaurer la propriété APO à la racine, il suffit d'échanger sa valeur avec celle du fils qui contient la plus grande, ici \(12\) (cf. arbre au centre de la figure). On repousse alors le problème sur la racine du sous-arbre où l'on a fait l'échange, ici le sous-arbre gauche, et il suffit de recommencer l'opération jusqu'à ce que la propriété soit rétablie, au pire en rencontrant une feuille. Dans notre exemple, la valeur 3 s'arrête dans sa descente après la transposition avec \(10\) puisque la propriété apo est alors vérifiée. L'opération qui consiste à faire descendre la valeur de la racine dans l'arbre doit bien entendu être réalisée sur le tableau T. Le lecteur démontrera le lemme suivant :
Même si nous n'avons pas étudié la récursivité, la nature récurrente de la structure d'arbre et du processus de tamisage est un bon prétexte pour écrire un premier algorithme récursif, en guise d'introduction au cours de troisième année. La simplicité de l'écriture ne devrait pas effrayer outre mesure le lecteur. L'algorithme Tamiser a trois paramètres, le tas, l'index du père et l'index de la dernière cellule à considérer dans le tableau. La base récurrente de l'algorithme consiste à ne rien faire si la propriété APO est satisfaite pour ce nœud. Les premières instructions (#9 à 12) de l'algorithme servent à trouver la valeur k de l'indice du fils avec lequel il faudra faire l'échange, c'est-à-dire le plus grand des deux fils.
Tamiser(@T,ipere,ifin) données T: tableau de valeurs ipere,ifin: entiers variables ifils: entier debut ifils ← 2 * ipere SI ((ifils < ifin) ET (T[ifils+1] > T[ifils])) ALORS ifils ← ifils + 1 FSI SI ((ifils ≤ ifin) ET (T[ipere] < T[ifils])) ALORS Echanger(T,ipere,ifils) Tamiser(T,ifils,ifin) FSI FIN
Notons que le troisième paramètre de l'algorithme peut sembler superflu mais sera nécessaire pour faire le tri. La preuve du résultat suivant qui justifie la validité de l'algorithme est laissée au lecteur.
Le tri d'un tas coule alors de source, l'arbre ayant retrouvé sa propriété APO, la plus grande valeur du tableau est de nouveau en première position et il suffit de l'échanger avec l'avant dernière valeur et de recommencer le processus jusqu'à ce que toutes les plus grandes valeurs aient été rangées à la fin du tableau. Supposons que l'on dispose d'un algorithme Entasser (nous le décrirons plus loin) qui transforme un tableau quelconque en tas, le tri s'écrit facilement:
TriTas(@T) données T: tableau de valeurs variables k: entier debut Entasser(T) k ← #T TQ (k > 0) FAIRE Echanger(T,1,k) Tamiser(T,1,k - 1) k ← k - 1 FTQ fin
Conversion d'un tableau en tas.
Il reste à présent à convertir un tableau quelconque \(T\) en tas. Pour celà, il faut rétablir la propriété \((\ref{eq:apo})\) en chaque nœud où elle fait défaut. C'est bien sûr l'algorithme Tamiser qui va être appliqué sur les nœuds en question. On pourrait être tenté de traiter les nœuds dans l'ordre de lecture, donc en commençant par la racine de l'arbre, mais le tableau \(T=[1,3,2,4]\) est transformé en \(T=[3,4,2,1]\) par l'algorithme et ce n'est pas un tas. Le lemme 8 indique comment procéder. En commençant par le bas, on s'assure ainsi que les sous-arbres gauche et droit sont toujours APO avant d'appliquer l'algorithme. Comme les feuilles sont toujours APO, il est inutile de les tester. Reste alors à déterminer par quel nœud commencer ?
Entasser(@T) données T: tableau de valeurs variables i: entier debut i ← #T / 2 TQ (i > 0) FAIRE Tamiser(T,i,#T) i ← i - 1 FTQ fin
Complexité
Nous allons estimer la complexité en calculant le nombre de fois où l'on teste la propriété APO qui est la clef du fonctionnement de l'algorithme. Ce nombre de tests permet aisément de déterminer le coût global puisque si la condition est satisfaite il n'y a rien à faire, sinon il faut échanger un père avec l'un de ses fils et dans les deux cas, le coût est constant. Donc un test a une complexité en \(\Theta(1)\).
Commençons par l'algorithme Entasser qui transforme un tableau en tas. Les opérations réalisées par l'algorithme Entasser, hormis celles réalisées lors des éventuels appels récursifs, est en \(\Theta(1)\), il suffit donc de compter la différence de profondeur entre le nœud d'indice \(i\) et le dernier avec lequel se fera un échange de valeurs.
Dans le meilleur des cas le tableau est déjà un tas et aucun échange n'a lieu en parcourant les \(\lfloor n/2\rfloor\) premiers nœuds, la complexité est donc \(\check T_{\text{Entasser}}(n)=\Theta(n)\). Dans le pire des cas, chacun des \(\lfloor n/2\rfloor\) premiers nœuds viole la propriété APO et on suppose qu'il va descendre jusqu'à une feuille. La profondeur d'un arbre étant le logarithme du nombre de ses nœuds on pourrait penser que chacun des \(\lfloor n/2\rfloor\) nœuds va faire un chemin de longueur \(\log_2 n\) et conclure que la complexité dans le pire des cas est en \(n\log n\), mais la situation s'avère bien meilleure comme nous allons le montrer. Le résultat suivant va nous être utile :
Dans le cas défavorable, la valeur d'un nœud d'indice \(i\) peut descendre jusqu'à une feuille qui est située à la profondeur maximale \(h:=\lfloor\log_2 n\rfloor\) ou en \(h-1\). La différence de profondeur est donc majorée par \(h-\lfloor\log_2 i\rfloor\) et par conséquent le nombre d'appels à \(h-\lfloor\log_2 i\rfloor+1\). On a donc la majoration suivante : \begin{align} \hat T_{\text{Entasser}}\;(n)&\leq \sum_{i=1}^{\lfloor \frac{n}{2}\rfloor}(h-\lfloor\log_2 i\rfloor+1)\\ &\leq\lfloor\frac{n}{2}\rfloor (h+1)-\sum_{i=1}^{\lfloor n/2\rfloor}\lfloor\log_2 i\rfloor \end{align}
Mais par définition de la partie entière, \(\lfloor \log_2 i\rfloor\leq \log_2 i <\lfloor\log_2 i\rfloor + 1\), donc \((\log_2 i) -1 < \lfloor \log_2 i\rfloor\). On a ainsi \begin{align} \hat T_{\text{Entasser}}\;(n) &\leq \lfloor\frac{n}{2}\rfloor (h+1)-\sum_{i=1}^{\lfloor n/2\rfloor}((\log_2 i)-1)\\ &\leq \lfloor\frac{n}{2}\rfloor (\log_2 n+2)-\sum_{i=1}^{\lfloor n/2\rfloor}\log_2 i\\ &\leq \lfloor\frac{n}{2}\rfloor\log_2 n + n-{\color{yellow}\sum_{i=1}^{\lfloor n/2\rfloor}\log_2 i}\label{eqtas}\quad\text{car}\ 2\lfloor\frac{n}{2}\rfloor\leq n \end{align} On minore la somme par une intégrale en rappelant que la primitive de \(\ln x\) est \(x\,\ln x -x\): \begin{align}\label{eq:majint} {\color{yellow}\sum_{i=1}^{\lfloor n/2\rfloor}\log_2 i}= \sum_{i=2}^{\lfloor n/2\rfloor}\log_2 i &\geq\frac{1}{\ln 2}\int_{1}^{\lfloor n/2\rfloor}\ln x\, dx\\ &\geq\frac{1}{\ln 2}\left[x\ln x -x\right]_{1}^{\lfloor n/2\rfloor}\\ &\geq\frac{1}{\ln 2}\left(\lfloor\frac{n}{2}\rfloor\ln\lfloor\frac{n}{2}\rfloor-\lfloor\frac{n}{2}\rfloor+1\right)\\ &\geq \lfloor\frac{n}{2}\rfloor \log_2\lfloor\frac{n}{2}\rfloor - \frac{1}{\ln 2}(\lfloor\frac{n}{2}\rfloor-1)\\ &\geq \lfloor\frac{n}{2}\rfloor \log_2\lfloor\frac{n}{2}\rfloor - 2(\lfloor\frac{n}{2}\rfloor-1)\quad\text{car}\ 1/(\ln 2)<2 \\ &\geq \lfloor\frac{n}{2}\rfloor \log_2\lfloor\frac{n}{2}\rfloor - 2\lfloor\frac{n}{2}\rfloor \\ &\geq \lfloor\frac{n}{2}\rfloor \left(\log_2\lfloor\frac{n}{2}\rfloor - 2\right) \end{align} En injectant ce résultat dans (\ref{eqtas}) : \begin{align} \hat T_{\text{Entasser}}\;(n) &\leq \lfloor\frac{n}{2}\rfloor\log_2 n + n-\color{yellow}{\lfloor\frac{n}{2}\rfloor \left(\log_2\lfloor\frac{n}{2}\rfloor - 2\right)}\\ &\leq \lfloor\frac{n}{2}\rfloor\left(\log_2n-\log_2\lfloor\frac{n}{2}\rfloor \right)+n-2\lfloor\frac{n}{2}\rfloor\\ &\leq \underbrace{\lfloor\frac{n}{2}\rfloor\left(\log_2n-\log_2\lfloor\frac{n}{2}\rfloor \right)+1}_{f(n)} \end{align}
On a \(f(2k)=k+1\), donc si \(n\) est pair \(\hat T(n)\leq \frac{n}{2}+1\) et si \(n\) est impair, \(\hat T(n)\leq f(n+1)=\frac{n}{2}+\frac{3}{2}\) donc \(\hat T_{\text{Entasser}}(n)=O(n)\). On peut de la même manière obtenir la minoration \(\Omega(n)\) et conclure \[\hat T_{\text{Entasser}}(n)=\Theta(n).\]
Notons que l'analyse du meilleur des cas et du cas moyen sont très difficiles et n'ont été obtenus qu'en 1992 par R. Shaeffer et R. Sedgewick et publiés dans The analysis of heapsort, Journal of Algorithms, Vol. 15, #1, pp. 76-100, 1993. Dans les deux cas la complexité est en \(\Theta(n\log n)\) mais le cas moyen est 2 fois plus coûteux que le meilleur des cas.
Travaux pratiques
Attention ! En \(C\) les indices des tableaux commencent à \(0\), il faut donc adapter toutes les formules des algorithmes.
Pour cela, reprenez la fonction uint *GenPerm(uint n) qui renvoie une permutation de taille \(n\) dont les valeurs ont été tirées au hasard. Reprenez cette suggestion pour engendrer une permutation aléatoire.