Éléments essentiels en algorithmique
Éléments essentiels en algorithmique
Ensta - in101
Année 2011-2012
Table des matières
1 Complexité 1
1.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.1.1 Comparaison asymptotique de fonctions . . . . . . . . . . . . . . . 1
1.1.2 Complexité d’un algorithme . . . . . . . . . . . . . . . . . . . . . . 2
1.1.3 Complexité spatiale, complexité variable . . . . . . . . . . . . . . . 3
1.2 Un seul problème, quatre algorithmes . . . . . . . . . . . . . . . . . . . . . 6
1.3 Un premier algorithme pour le tri . . . . . . . . . . . . . . . . . . . . . . . 9
2 Récursivité 11
2.1 Conception des algorithmes . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.2 Problème des tours de Hanoı̈ . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.3 Algorithmes de tri . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.3.1 Le tri fusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.3.2 Le tri rapide . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.3.3 Complexité minimale d’un algorithme de tri . . . . . . . . . . . . . 21
2.4 Résolution d’équations de récurrence . . . . . . . . . . . . . . . . . . . . . 22
2.4.1 Récurrences linéaires . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.4.2 ⋆ Récurrences de partitions . . . . . . . . . . . . . . . . . . . . . . 24
2.5 ⋆ Compléments . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.5.1 ⋆ Récursivité terminale . . . . . . . . . . . . . . . . . . . . . . . . 28
2.5.2 ⋆ Dérécursification d’un programme . . . . . . . . . . . . . . . . . 28
2.5.3 ⋆ Indécidabilité de la terminaison . . . . . . . . . . . . . . . . . . . 30
3 Structures de Données 35
3.1 Tableaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
3.1.1 Allocation mémoire d’un tableau . . . . . . . . . . . . . . . . . . . 36
3.1.2 ⋆ Complément : allocation dynamique de tableau . . . . . . . . . . 39
3.2 Listes chaı̂nées . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
3.2.1 Opérations de base sur une liste . . . . . . . . . . . . . . . . . . . . 41
3.2.2 Les variantes : doublement chaı̂nées, circulaires... . . . . . . . . . . 43
3.2.3 Conclusion sur les listes . . . . . . . . . . . . . . . . . . . . . . . . 44
3.3 Piles & Files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
3.3.1 Les piles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
3.3.2 Les files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
4 Recherche en table 49
4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
4.2 Table à adressage direct . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.3 Recherche séquentielle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
4.4 Recherche dichotomique . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
4.5 Tables de hachage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
4.6 Tableau récapitulatif des complexités . . . . . . . . . . . . . . . . . . . . . 57
5 Arbres 59
5.1 Préliminaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
5.1.1 Définitions et terminologie . . . . . . . . . . . . . . . . . . . . . . . 59
5.1.2 Premières propriétés . . . . . . . . . . . . . . . . . . . . . . . . . . 61
5.1.3 Représentation des arbres . . . . . . . . . . . . . . . . . . . . . . . 62
5.2 Utilisation des arbres . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
5.2.1 Évaluation d’expressions & parcours d’arbres . . . . . . . . . . . . . 64
5.2.2 Arbres Binaires de Recherche . . . . . . . . . . . . . . . . . . . . . 66
5.2.3 Tas pour l’implémentation de files de priorité . . . . . . . . . . . . 72
5.2.4 Tri par tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
5.3 Arbres équilibrés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
5.3.1 Rééquilibrage d’arbres . . . . . . . . . . . . . . . . . . . . . . . . . 78
5.3.2 Arbres AVL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
6 Graphes 85
6.1 Définitions et terminologie . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
6.2 Représentation des graphes . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
6.2.1 Matrice d’adjacence . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
6.2.2 Liste de successeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
6.3 Existence de chemins & fermeture transitive . . . . . . . . . . . . . . . . . 89
6.4 Parcours de graphes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
6.4.1 Arborescences . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
6.4.2 Parcours en largeur . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
6.4.3 Parcours en profondeur . . . . . . . . . . . . . . . . . . . . . . . . . 97
6.5 Applications des parcours de graphes . . . . . . . . . . . . . . . . . . . . . 99
6.5.1 Tri topologique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
6.5.2 ⋆ Calcul des composantes fortement connexes . . . . . . . . . . . . 100
6.5.3 Calcul de chemins optimaux . . . . . . . . . . . . . . . . . . . . . . 102
Complexité
La notion de complexité est centrale en algorithmique : c’est grâce à elle que l’on peut
définir ce qu’est un bon algorithme. La recherche d’algorithmes ayant une complexité plus
petite que les meilleurs algorithmes connus est un thème de recherche important dans toutes
les branches de l’informatique et des mathématiques appliquées. Dans ce chapitre nous
voyons comment est définie cette notion de complexité.
1.1 Définitions
1.1.1 Comparaison asymptotique de fonctions
Commençons par un rappel de quelques notations : pour deux fonctions réelles f (n) et
g(n), on écrira :
f (n) = O(g(n)) (1.1)
si et seulement s’il existe deux constantes strictement positives n0 et c telles que :
∀n > n0 , 0 ≤ f (n) ≤ c × g(n).
Inversement, quand g(n) = O(f (n)), on utilise la notation :
f (n) = Ω(g(n)) (1.2)
et, quand on a à la fois les propriétés (1.1) et (1.2) :
f (n) = Θ(g(n)). (1.3)
Plus formellement, f (n) = Θ(g(n)) si :
∃(c, c′ ) ∈ (R∗+ )2 , ∃n0 ∈ N, ∀n ≥ n0 , c × g(n) ≤ f (n) ≤ c′ × g(n).
Notons que la formule (1.3) ne signifie pas que f (n) est équivalente à g(n) (noté f (n) ∼
g(n)), qui se définit comme :
f (n) − g(n)
lim = 0.
n→∞ g(n)
1
§ 1.1 Définitions ENSTA – cours IN101
Cependant, si f (n) ∼ g(n) on a f (n) = Θ(g(n)). Enfin, il est clair que f (n) = Θ(g(n))
si et seulement si g(n) = Θ(f (n)). Intuitivement, la notation Θ revient à ≪ oublier ≫ le
coefficient multiplicatif constant de g(n).
Voici quelques exemples de comparaisons de fonctions :
– n2 + 3n + 1 = Θ(n2 ) = Θ(50n2 ),
– n/ log(n) = O(n),
– 50n10 = O(n10,01 ),
– 2n = O(exp(n)),
– exp(n) = O(n!), √
– n/ log(n) = Ω( n),
– log2 (n) = Θ(log(n)) = Θ(ln(n)).
On peut ainsi établir une hiérarchie (non exhaustive) entre les fonctions :
√ n
log(n) ≪ n ≪ n ≪ n log(n) ≪ n2 ≪ n3 ≪ 2n ≪ exp(n) ≪ n! ≪ nn ≪ 22
Pour un tel algorithme on considère que la taille de l’entrée est n et on cherche à compter
le nombre d’opérations de base en fonction de n. L’algorithme effectue des multiplications
et des additions, donc il convient de considérer ces opérations comme opérations de base.
Il y a au total n multiplications et n additions, donc l’algorithme a une complexité Θ(n).
Le choix de l’opération de base semble ici tout à fait raisonnable car dans un processeur
2 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 1. Complexité
moderne l’addition et la multiplication sont des opérations qui prennent Θ(1) cycles pour
être effectués.
Imaginons maintenant que l’on modifie l’algorithme pour qu’il puisse manipuler des
grands entiers (plus grands que ce que le processeur peut manipuler d’un seul coup) : le
coût d’une addition ou d’une multiplication va augmenter quand la taille des entiers va
augmenter, et ces opérations n’ont alors plus un coût constant. Il convient alors de changer
d’opération de base et de considérer non plus des multiplications/additions complexes mais
des opérations binaires élémentaires. Le coût de l’addition de deux nombre entre 0 et n est
alors Θ(log n) (le nombre de bits nécessaires pour écrire n) et le coût d’une multiplication
(en utilisant un algorithme basique) est Θ((log n)2 ). Calculer la somme des carrés de 1 à
n quand n devient grand a donc une complexité Θ(n(log n)2 ) opérations binaires.
Comme vous le verrez tout au long de ce poly, selon le contexte, l’opération de base à
choisir peut donc changer, mais elle reste en général l’opération la plus naturelle.
& M. Finiasz 3
§ 1.1 Définitions ENSTA – cours IN101
Complexité dans le pire cas. Comme nous avons vu, la complexité d’un algorithme
s’exprime en fonction de la taille de l’entrée à traiter. Cependant, certains algorithmes
peuvent avoir une complexité très variable pour des entrées de même taille : factoriser un
nombre va par exemple dépendre plus de la taille du plus grand facteur que de la taille
totale du nombre.
Une technique d’étude des performances d’un tel algorithme à complexité variable
consiste à examiner la complexité en temps du pire cas. Le temps de calcul dans le pire
cas pour les entrées x de taille n fixée est défini par
T (n) fournit donc une borne supérieure sur le temps de calcul sur n’importe quelle
4 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 1. Complexité
entrée de taille n. Pour calculer T (n), on néglige les constantes dans l’analyse, afin de
pouvoir exprimer comment l’algorithme dépend de la taille des données. Pour cela, on
utilise la notation O ou Θ, qui permet de donner un ordre de grandeur sans caractériser
plus finement. La complexité dans le pire cas est donc indépendante de l’implémentation
choisie.
Complexité moyenne. Une autre façon d’étudier les performances d’un algorithme
consiste à en déterminer la complexité moyenne : c’est la moyenne du temps de calcul sur
toutes les données d’une taille n fixée, en supposant connue une distribution de probabilité
(p(n)) sur l’ensemble des entrées de taille n :
∑
Tm (n) = pn (x)T (x).
x, |x|=n
On remarque que Tm (n) n’est autre que l’espérance de la variable aléatoire ≪ temps de cal-
cul ≫. Le cas le plus aisé pour déterminer Tm (n) est celui où les données sont équidistribuées
dans leur ensemble de définition : on peut alors dans un premier temps évaluer le temps
de calcul T (x) de l’algorithme sur une entrée x choisie aléatoirement dans cet ensemble,
le calcul de la moyenne des complexités sur toutes les entrées de l’ensemble s’en déduisant
facilement 1 .
Lorsque la distribution (p(n)) n’est pas la distribution uniforme, la complexité moyenne
d’un algorithme est en général plus difficile à calculer que la complexité dans le pire cas ;
de plus, l’hypothèse d’uniformité peut ne pas refléter la situation pratique d’utilisation de
l’algorithme.
& M. Finiasz 5
§ 1.2 Un seul problème, quatre algorithmes ENSTA – cours IN101
Pour ces raisons, le temps de calcul dans le pire cas est bien plus souvent utilisé comme
mesure de la complexité.
L’algorithme obtenu est un algorithme récursif (cf. chapitre 2). Si l’on note Cn , le
nombre d’appels à fibo1 nécessaires au calcul de Fn , on a, C0 = C1 = 1, et, pour n ≥ 2,
Cn = 1 + Cn−1 + Cn−2 .
Si l’on pose Dn = (Cn + 1)/2, on observe que Dn suit exactement la relation de récurrence
définissant la suite de Fibonacci (seules les conditions initiales diffèrent).
La résolution de cette récurrence linéaire utilise une technique d’algèbre classique (cf.
chapitre 2) : on calcule les solutions
√
de l’équation
√
caractéristique de cette récurrence - ici
2 1+ 5 1− 5
x − x − 1 = 0 - qui sont ϕ = 2 et ϕ̄ = 2 . Dn s’écrit alors
Dn = λϕn + µϕ̄n .
6 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 1. Complexité
On prend ici comme unité de mesure de complexité le temps de calcul d’une opération
d’addition, de soustraction ou de multiplication. La complexité de l’algorithme Fibo2 est
alors Θ(n), i.e. linéaire en n (en effet, la boucle for comporte n − 1 itérations, chaque
itération consistant en une addition). La complexité est donc drastiquement réduite par
rapport à l’algorithme précédent : le prix à payer ici est une complexité en espace linéaire
(Θ(n) pour stocker le tableau fib).
On remarque maintenant que les n − 2 valeurs Fk , 0 ≤ k ≤ n − 3 n’ont pas besoin d’être
stockées pour le calcul de Fn . On peut donc revenir à une complexité en espace en Θ(1) en
ne conservant que les deux dernières valeurs courantes Fk−1 et Fk−2 nécessaires au calcul
de Fk : on obtient le troisième algorithme suivant :
& M. Finiasz 7
§ 1.2 Un seul problème, quatre algorithmes ENSTA – cours IN101
( )
1 1
Ainsi, calculer Fn revient à mettre à la puissance (n − 1) la matrice .
1 0
La complexité en temps de l’algorithme qui en découle est ici Θ(log(n)) multiplications
de matrices carrées 2 × 2 : en effet, c’est le temps requis par l’algorithme d’exponentiation
≪ square-and-multiply ≫ (cf. TD 01) pour calculer la matrice. L’espace nécessaire est celui
du stockage de quelques matrices carrées 2 × 2, soit Θ(1). L’algorithme fibo4 peut s’écrire
ainsi :
28 while (i > 0) {
29 /* on élève au carré */
30 mat_tmp[0][0] = res[0][0]*res[0][0] + res[0][1]*res[1][0];
31 mat_tmp[0][1] = res[0][0]*res[0][1] + res[0][1]*res[1][1];
32 mat_tmp[1][0] = res[1][0]*res[0][0] + res[1][1]*res[1][0];
33 mat_tmp[1][1] = res[1][0]*res[0][1] + res[1][1]*res[1][1];
34 /* on regarde la valeur du ième bit de n-1
35 pour savoir si on doit faire une multiplication */
36 if (((n-1) & (1<<(i-1))) != 0) {
37 res[0][0] = mat_tmp[0][0] + mat_tmp[0][1];
38 res[0][1] = mat_tmp[0][0];
39 res[1][0] = mat_tmp[1][0] + mat_tmp[1][1];
40 res[1][1] = mat_tmp[1][0];
41 } else { /* on replace la matrice dans res */
42 res[0][0] = mat_tmp[0][0];
8 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 1. Complexité
43 res[0][1] = mat_tmp[0][1];
44 res[1][0] = mat_tmp[1][0];
45 res[1][1] = mat_tmp[1][1];
46 }
47 i--;
48 }
49 return res[0][0];
50 }
Ainsi, une étude algorithmique préalable du problème de départ conduit à une réduction
parfois drastique de la complexité de sa résolution, ce qui a pour conséquence de permettre
d’atteindre des tailles de paramètres inenvisageables avec un algorithme irréfléchi... La
Table 1.1 ci-dessous illustre les temps de calcul des quatre algorithmes précédents.
Comme on le voit dans la Table 1.1, l’algorithme fibo4 qui a la meilleure complexité
est beaucoup plus rapide que les autres, et peut traiter des valeurs de n plus grandes.
L’algorithme fibo1 prend très vite un temps trop élevé pour pouvoir être utilisé. Une autre
limitation de fibo1 que l’on ne voit pas dans le tableau est la limite liée au nombre maximal
de niveaux de récursivité autorisés dans un programme : en C cette valeur est souvent
autour de quelques dizaines de milliers 2 . On ne peut donc pas avoir plus que ce nombre
d’appels récursifs imbriqués au sein d’un algorithme. Pour l’algorithme fibo2 la limite ne
vient pas du temps de calcul, mais de la taille mémoire nécessaire : quand on essaye d’allouer
un tableau de 228 entiers (soit 1Go de mémoire d’un seul bloc) le système d’exploitation
n’arrive pas à satisfaire notre requête et n’alloue donc pas de mémoire, et puisque l’on ne
teste pas si la mémoire a bien été allouée avant d’écrire dans notre tableau, un problème à
l’allocation se traduit immédiatement par une erreur de segmentation mémoire à l’écriture.
& M. Finiasz 9
§ 1.3 Un premier algorithme pour le tri ENSTA – cours IN101
Le principe de cet algorithme est assez simple : à chaque étape de la boucle for, les i
premiers éléments de tab sont triés par ordre croissant. Quand i = 1 c’est vrai, et à chaque
fois que l’on augmente i de 1, la boucle while se charge de replacer le nouvel élément à sa
place en le remontant élément par élément vers les petits indice du tableau : si le nouvel
élément est plus petit que celui juste avant, on inverse les deux éléments que l’on vient de
comparer et on recommence jusqu’à avoir atteint la bonne position.
Cet algorithme a un coût très variable en fonction de l’état initial du tableau :
– pour un tableau déjà trié, l’algorithme va simplement parcourir tous les éléments et les
comparer à l’élément juste avant eux, mais ne fera jamais d’échange. Le coût est alors
de n comparaisons.
– pour un tableau trié à l’envers (le cas le pire), l’algorithme va remonter chaque nouvel
élément tout au début du tableau. Il y a alors au total exactement n(n−1)
2
comparaisons
2
et échanges. L’algorithme de tri par insertion à donc une complexité Θ(n ) dans le pire
des cas.
– en moyenne, il faudra remonter chaque nouvel élément sur la moitié de la longueur, donc
i−1
2
comparaisons et échange par nouvel élément. Au final la complexité en moyenne du
tri par insertion est Θ(n2 ), la même que le cas le pire (on perd le facteur 21 dans le Θ).
Généralisations du tri par insertion. Il existe plusieurs variantes du tri par insertion
visant à améliorer sa complexité en moyenne. Citons en particulier le tri de Shell (cf. http:
//[Link]/wiki/Tri_de_Shell) qui compare non pas des éléments voisins,
mais des éléments plus distants afin d’optimiser le nombre de comparaisons nécessaires.
10 F. Levy-dit-Vehel
Chapitre 2
Récursivité
11
§ 2.2 Problème des tours de Hanoı̈ ENSTA – cours IN101
12 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
1 2 3
Figure 2.1 – Le jeu des tours de Hanoı̈. Déplacement d’une rondelle du piquet 1 vers
le piquet 2. En pointillés : l’objectif final.
Cet algorithme, que l’on appelle Hanoı̈, suit l’approche diviser pour régner : la phase de
division consiste toujours à diviser le problème de taille n en deux sous-problèmes de taille
n − 1 et 1 respectivement. La phase de règne consiste à appeler récursivement l’algorithme
Hanoı̈ sur le sous-problème de taille n − 1. Ici, il y a deux ≪ séries ≫ d’appels récursifs
par sous-problème de taille n − 1 (étapes 1. et 3.). La phase de combinaison des solutions
des sous-problèmes est inexistante ici. On donne ci-après le programme C implémentant
l’algorithme Hanoı̈ :
& M. Finiasz 13
§ 2.2 Problème des tours de Hanoı̈ ENSTA – cours IN101
14 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
La procédure fusion (la fonction merge décrite ci-dessous) commence par recopier les
deux sous-tableaux triés tab[p]...tab[q-1] et tab[q]...tab[r-1] ≪ dos-à-dos 2 ≫ dans un
tableau auxiliaire tmp. L’intérêt de cette façon de recopier est que l’on n’a alors pas besoin
de rajouter de tests de fin de sous-tableaux, ni de case supplémentaire contenant le symbole
∞ par exemple à chacun des sous-tableaux. Ensuite, merge remet dans tab les éléments
du tableau tmp trié, mais ici le tri a une complexité linéaire (Θ(n)) puisque tmp provient
1. La récursion s’arrête lorsque les sous-tableaux sont de taille 1, donc trivialement triés.
2. Ainsi, tmp est le tableau [tab[p],...,tab[q-1],tab[r-1],...,tab[q]].
& M. Finiasz 15
§ 2.3 Algorithmes de tri ENSTA – cours IN101
16 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
& M. Finiasz 17
§ 2.3 Algorithmes de tri ENSTA – cours IN101
revanche, son comportement dépend de la distribution de son entrée : dans le pire cas,
il possède une complexité quadratique, cependant, ses performances en moyenne sont en
Θ(n log(n)), et il constitue souvent le meilleur choix en pratique 4 . Le fonctionnement de
l’algorithme sur un tableau tab à trier entre les indices p et r est le suivant :
À la fin de chacune des itérations de la boucle for, le tableau est divisé en trois parties :
∀i, p ≤ i ≤ q, tab[i] ≤ x
18 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
Pour j ≤ i ≤ r, les clefs tab[i] n’ont pas de lien fixé avec le pivot x (éléments non encore
traités).
Si le test en ligne 6 est vrai, alors l’élément en position j est inférieur ou égal à x, donc
on le décale le plus à gauche possible (lignes 8 à 10) ; mais on incrémente d’abord q (ligne
7) de façon à pouvoir insérer cet élément entre tab[p] et tab[q] strictement. À l’issue de
la boucle for sur j, tous les éléments de tab[p]...tab[r-1] inférieurs ou égaux à x ont été
placés à gauche de tab[q] (et à droite de tab[p]) ; il ne reste donc plus qu’à mettre x à
la place de tab[q] (lignes 13 à 15).
T (n) = T (n − 1) + D(n),
(la procédure quick sort sur une entrée de taille 0 ne nécessitant pas de comparaison)
où D(n) est le coût de la procédure partition sur une entrée de taille n. Il est clair que
partition(tab, p, r) nécessite r − p − 1 comparaisons, donc D(n) = n − 1. L’arbre récursif
correspondant à T (n) = T (n − 1) + n − 1 est de profondeur n − 1, le niveau de profondeur
i ayant un coût de n − (i + 1). En cumulant les coûts par niveau, on obtient
n−1 n−1
∑ ∑ n(n − 1)
T (n) = n − (i + 1) = i= .
i=0 i=0
2
Ainsi, la complexité de l’algorithme dans le pire cas est en Θ(n2 ), i.e. pas meilleure que
celle du tri par insertion.
Le cas le plus favorable est celui où la procédure partition découpe toujours le ta-
bleau courant en deux sous-tableaux de taille presque égale (⌊ n2 ⌋ et ⌈ n2 ⌉ − 1, donc toujours
inférieure ou égale à n2 ). Dans ce cas, la complexité de l’algorithme est donné par
T (n) = 2 × T ( n2 ) + n − 1.
Cette récurrence est similaire à celle rencontrée lors de l’étude du tri fusion. La solution
est T (n) = (n − 1) log(n) si n est une puissance de 2, soit T (n) = Θ(n log(n)) pour tout n.
Nous allons maintenant voir que cette complexité est aussi celle du cas moyen. Pour
cela, il est nécessaire de faire une hypothèse sur la distribution des entiers en entrée de
l’algorithme. On suppose donc qu’ils suivent une distribution aléatoire uniforme. C’est
le résultat de partition qui, à chaque appel récursif, conditionne le découpage en sous-
tableaux et donc la complexité. On ne peut pas supposer que partition fournit un indice
q uniformément distribué dans [p, ..., r − 1] : en effet, on choisit toujours comme pivot le
& M. Finiasz 19
§ 2.3 Algorithmes de tri ENSTA – cours IN101
L’échange a pour effet de placer le pivot tab[i] en position p, ce qui permet d’exécuter
partition normalement).
En supposant à présent que l’entier q retourné par random partition est uniformément
distribué dans [p, r − 1], on obtient la formule suivante pour T (n) :
n
1∑
T (n) = n − 1 + (T (q − 1) + T (n − q)).
n q=1
En effet, nq=1 (T (q −1)+T (n−q)) représente la somme des complexités correspondant aux
∑
n découpages possibles du tableau tab[0, ..., n − 1] en deux sous-tableaux. La complexité
moyenne T (n) est donc la moyenne de ces complexités. Par symétrie, on a :
n
2∑
T (n) = n − 1 + T (q − 1),
n q=1
ou n
∑
nT (n) = n(n − 1) + 2 T (q − 1).
q=1
ou
T (n) T (n − 1) 2(n − 1)
= + .
n+1 n n(n + 1)
5. Et ce, même si les entiers en entrée sont uniformément distribués : en effet, la configuration de ces
entiers dans le tableau est modifiée d’un appel de partition à l’autre.
20 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
En itérant ce processus :
n n
T (n) ∑ 2(k − 1) ∑ 2(k − 1)
= , ou T (n) = (n + 1) .
n + 1 k=2 k(k + 1) k=2
k(k + 1)
Ainsi, T (n) ≈ 2(n + 1)ln(n) et on retrouve bien une complexité en Θ(n log(n)) pour le cas
moyen.
& M. Finiasz 21
§ 2.4 Résolution d’équations de récurrence ENSTA – cours IN101
se trouve sur un chemin de longueur 6 h). Le nombre de feuilles d’un arbre binaire de
hauteur h étant au plus 2h , on a
La formule de Stirling
√ n 1
n! = 2πn( )n (1 + Θ( )),
e n
donne
n
log(n!) > log(( )n ) = nlog(n) − nlog(e),
e
et par suite, h = Ω(n log(n)). Nous énonçons ce résultat en :
Théorème 2.3.1. Tout algorithme de tri par comparaison nécessite Ω(n log(n)) compa-
raisons dans le pire cas.
Les tris fusion et rapide sont donc des tris par comparaison asymptotiquement opti-
maux.
Cette suite est entièrement déterminée par ses h premières valeurs u0 , . . . , uh−1 (la suite
est d’ordre h). Son polynôme caractéristique est :
∑∞ωi , 1 ≤ ni ≤ r. En considérant la
Soit ω1 , . . . , ωr , ses racines, et soit ni , la multiplicité de
série génératrice formelle associée à un , soit U (X) = n=0 un X , et en multipliant cette
22 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
Notons n(x, y), le nombre de divisions avec reste effectuées par l’algorithme. Alors n(x, y) =
0 si y = 0, et n(x, y) = 1 + n(y, x mod y) sinon. Pour évaluer le coût de l’algorithme (en
fonction de x), nous allons d’abord prouver que, pour x > y ≥ 0,
n(x, y) = k ⇒ x ≥ Fk+2 .
Pour k = 0, on a x ≥ 1 = F2 , et, pour k = 1, on a x ≥ 2 = F3 . Supposons à présent k ≥ 2,
et la propriété ci-dessus vraie pour tout j ≤ k − 1, i.e. si n(u, v) = j, alors u ≥ Fj+2 , pour
u, v ∈ N, u ≥ v > 0. Supposons n(x, y) = k, et considérons les divisions euclidiennes :
x = qy + z, 0 ≤ z < y, y = q ′ z + u, 0 ≤ u < z.
On a n(y, z) = k − 1 donc y ≥ Fk+1 ; de même, z ≥ Fk et par suite √
x ≥ Fk+1 + Fk = Fk+2 .
1
n
D’autre part, comme ϕ̄ ≤ 1, ∀n ∈ N, on a Fn ≥ 5 (ϕ − 1). D’où 5x + 1 ≥ ϕk+2 . Donc,
√ n
pour x > y ≥ 0, √
n(x, y) ≤ logϕ ( 5x + 1) − 2,
7. Le détail des calculs peut être trouvé dans Éléments d’algorithmique, de Beauquier, Berstel,
Chrétienne, éd. Masson.
& M. Finiasz 23
§ 2.4 Résolution d’équations de récurrence ENSTA – cours IN101
24 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
On a :
ap = alogb (n) = blogb (n)logb (a) = Θ(bplogb (a) ) = Θ(nlogb (a) ),
d’où ap d = Θ(nlogb (a) ).
Dans le cas où f (n) = nk , le temps de calcul est donné par le théorème suivant.
Théorème 2.4.1. Soit T : N → R+ , une fonction croissante, telle qu’il existe des entiers
b ≥ 2, et des réels a > 0, c > 0, d > 0, k ≥ 0, pour lesquels
T (1) = d
(2.4)
T (n) = aT ( nb ) + cnk , n = bp , p ∈ N∗ .
Alors :
Θ(nk ) si a < bk
T (n) = Θ(nk logb (n)) si a = bk (2.5)
Θ(nlogb (a) ) si a > bk .
Preuve. On suppose d’abord que n est une puissance de b, soit n = bp , pour un entier
p ≥ 1. Par le lemme précédent,
p−1
logb (a)
∑
k a
T (n) = θ(n ) + cn ( k )i .
i=0
b
p−1
∑ a 1 − ( bak )p
γ(n) = ( k )i = .
i=0
b 1 − ( bak )
a
Si bk
< 1, alors limp→∞ ( bak )p = 0 donc γ(n) ∼ 1−(a/b1 k
k ) , et T (n) = Θ(n ).
a
Si bk
> 1,
( ak )p − 1 a
γ(n) = b a = α(( k )p − 1),
( bk ) − 1 b
& M. Finiasz 25
§ 2.4 Résolution d’équations de récurrence ENSTA – cours IN101
1
avec α = a/bk −1
.
γ(n) − α( bak )p −1 −1
a p = a p et p→∞
lim = 0,
α( bk ) α( bk ) α( bak )p
donc
γ(n) ∼ α( bak )p ,
et
cnk γ(n) ∼ αcap = Θ(nlogb (a) ) d’où T (n) = Θ(nlogb (a) ).
Maintenant, soit n suffisamment grand, et soit p ∈ N∗ , tel que bp ≤ n < bp+1 . On a T (bp ) ≤
T (n) ≤ T (bp+1 ). Or, g(bn) = Θ(g(n)) pour chacune des trois fonctions g intervenant au
second membre de (2.5) ; d’où T (n) = Θ(g(n)).
Remarques :
1. On compare k à logb (a). Le comportement asymptotique de T (n) suit nmax(k,logb (a)) ,
sauf pour k = logb (a), auquel cas on multiplie la complexité par un ≪ facteur correc-
tif ≫ logb (n).
2. Ce théorème couvre les récurrences du type (2.1), avec f (n) = cnk , c > 0, k ≥ 0.
Si f (n) = O(nk ), le théorème reste valable en remplaçant les Θ par des O. Mais
la borne obtenue peut alors ne pas être aussi fine 8 que celle obtenue en appliquant
directement la formule (2.3) (i.e. la méthode de l’arbre récursif).
Par exemple, si l’on a une récurrence de la forme :
T (1) = 0
T (n) = 2T (n/2) + nlog(n), n = 2p , p ∈ N∗ ,
la fonction f (n) = n log(n) vérifie f (n) = O(n3/2 ). Le théorème précédent donne T (n) =
O(n3/2 ) (a = b = 2, k = 3/2).
Supposons d’abord que n est une puissance de 2, soit n = 2p . Par l’expression (2.3) :
p−1 p−1 p−1
∑
i n n ∑ n ∑ p(p − 1)
T (n) = 2 × i log( i ) = n log( i ) = np log(n) − i = np log(n) − ,
i=0
2 2 i=0
2 i=0
2
soit
log(n)(log(n) − 1)
T (n) = n(log(n))2 − .
2
Ainsi, T (n) = Θ(n(log(n))2 ). On obtient le même résultat lorsque n n’est pas une puissance
de 2 en encadrant n entre deux puissances consécutives de 2.
En revanche, la récurrence
T (1) = 1 √ (2.6)
T (n) = 2T (n/4) + n2 n, n = 4p , p ∈ N∗ ,
8. Elle dépend de la finesse de l’approximation de f comme O(nk ).
26 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
& M. Finiasz 27
§ 2.5 ⋆ Compléments ENSTA – cours IN101
2.5 ⋆ Compléments
2.5.1 ⋆ Récursivité terminale
La récursivité terminale (tail recursivity en anglais) est un cas particulier de récursivité :
il s’agit du cas où un algorithme récursif effectue son appel récursif comme toute dernière
instruction. C’est le cas par exemple de l’algorithme d’Euclide vu page 23 : l’appel récursif
se fait dans le return, et aucune opération n’est effectuée sur le résultat retourné par
l’appel récursif, il est juste passé au niveau du dessus. Dans ce cas, le compilateur a la
possibilité d’optimiser l’appel récursif : au lieu d’effectuer l’appel récursif, récupérer le
résultat à l’adresse de retour qu’il a fixé pour l’appel récursif, et recopier le résultat à
sa propre adresse de retour, l’algorithme peut directement choisir de redonner sa propre
adresse de retour à l’appel récursif. Cette optimisation présente l’avantage de ne pas avoir
une pile récapitulant les appels récursifs aux différentes sous-fonctions : l’utilisation de
récursion terminale supprime toute limite sur le nombre d’appels récursifs imbriqués.
Bien sûr, pour que la récursion terminale présente un intérêt, il faut qu’elle soit gérée
par le compilateur. C’est le cas par exemple du compilateur Caml, ou (pour les cas simples
de récursivité terminale) de gcc quand on utilise les options d’optimisation -O2 ou -O3.
Attention !
Si l’appel récursif n’est pas la seule instruction dans le return, il devient impossible
pour le compilateur de faire l’optimisation : par exemple, un algorithme se terminant
par une ligne du type return n*recursif(n-1) ne fait pas de récursivité terminale.
28 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
Toutefois, comme nous l’avons vu dans la section précédente, il s’agit ici de récursion
terminale. Dans ce cas, de la même façon que le compilateur arrive à se passer de pile,
nous pouvons aussi nous passer d’une pile, et récrire le programme avec une simple boucle
while. On conserve la même condition de terminaison (sauf que dans la boucle while il
s’agit d’une condition de continuation qu’il faut donc inverser), les deux variables x et y,
et on se contente de mettre les bonnes valeurs dans x et y à chaque tour de la boucle. Cela
donne la version itérative de l’algorithme d’Euclide :
1 void depth_first_traversing(node n) {
2 if ( n != NULL ) {
3 explore(n);
4 depth_first_traversing(n->left);
5 depth_first_traversing(n->right);
6 }
7 return;
8 }
Ici node est une structure correspondant à un nœud de l’arbre. Cette structure contient
les deux fils du nœud left et right et toute autre donnée que peut avoir à contenir le
nœud. La fonction explore est la fonction que l’on veut appliquer à tous les nœuds de
l’arbre (cela peut-être une comparaison dans le cas d’une recherche dans l’arbre).
& M. Finiasz 29
§ 2.5 ⋆ Compléments ENSTA – cours IN101
Ici nous sommes en présence d’une récursion double, il est donc nécessaire d’utiliser
une pile pour gérer l’ensemble des appels récursifs imbriqués. On suppose donc qu’une
pile est implémentée (cf. section 3.3.1) et que les fonction push et pop nous permettent
respectivement d’ajouter ou de récupérer un élément dans cette pile. On suppose que la
fonction stack is empty renvoie 1 quand la pile est vide, 0 autrement. Ce qui va rendre
cette dérécursification plus facile que le cas général est qu’ici les différents appels à la
fonction sont indépendants les uns des autres : la fonction explore ne renvoie rien, et l’on
n’utilise pas son résultat pour modifier la façon dont le parcours va se passer. On obtient
alors le code itératif suivant :
1 void iterative_depth_first_traversing(node n) {
2 node current;
3 push(n);
4 while ( !stack_is_empty() ) {
5 current = pop();
6 if (current != NULL) {
7 explore(current);
8 push(current->right);
9 push(current->left);
10 }
11 }
12 return;
13 }
Le but est que chaque nœud qui est mis sur la pile soit un jour exploré, et que tous ses
fils le soient aussi. Il suffit donc de mettre la racine sur la pile au départ, et ensuite, tant
que la pile n’est pas vide d’explorer les nœuds qui sont dessus et à chaque fois d’ajouter
leurs fils. Il faut faire attention à ajouter les fils dans l’ordre inverse des appels récursifs
pour que le parcours se fasse bien dans le même ordre. En effet, le dernier nœud ajouté sur
la pile sera le premier exploré ensuite.
10. Dans le cas ou cette distance est discrète (si par exemple cette distance est toujours entière), une
décroissance stricte est suffisante, mais ce n’est pas le cas si la distance est continue (ce qui n’arrive jamais
en informatique !).
30 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
Ici la condition de terminaison est double : l’algorithme s’arrête quand l’un des deux bords
du triangle de Pascal est atteint. Pour prouver que l’un des bords est atteint on peut utiliser
la mesure D : (n, p) 7→ D(n, p) = p × (n − p). Ainsi, on est sur un bord quand la mesure
vaut 0 et on a D(n − 1, p) < D(n, p) et D(n − 1, p − 1) < D(n, p). Donc la distance décroı̂t
strictement à chaque appel récursif. Cela prouve donc que cet algorithme termine.
Notons toutefois que cette façon de calculer les coefficients binomiaux est très mauvaise,
il est bien plus rapide d’utiliser un algorithme itératif faisant le calcul du développement
en factoriels du coefficient binomial.
1 int collatz(int n) {
2 if (n==1) {
3 return 0;
4 } else if ((n%2) == 0) {
5 return 1 + collatz(n/2);
6 } else {
7 return 1 + collatz(3*n+1);
8 }
9 }
& M. Finiasz 31
§ 2.5 ⋆ Compléments ENSTA – cours IN101
La conjecture de Collatz (aussi appelée conjecture de Syracuse 11 ) dit que cet algorithme
termine toujours. Cependant, ce n’est qu’une conjecture, et aucune preuve n’est connue.
Nous somme donc dans un contexte où, pour un entier donné, la seule façon de savoir si l’al-
gorithme termine est d’exécuter l’algorithme et d’attendre : la terminaison est indécidable.
Le problème de l’arrêt de Turing. Dès 1936, Alan Turing s’est intéressé au problème
de la terminaison d’un algorithme, qu’il a formulé sous la forme du problème de l’arrêt
(halting problem en anglais) :
Étant donnée la description d’un programme et une entrée de taille finie, décider
si le programme va finir ou va s’exécuter indéfiniment pour cette entrée.
Il a prouvé qu’aucun algorithme générique ne peut résoudre ce problème pour tous les
couples programme/entrée possibles. Cela signifie en particulier qu’il existe des couples
programme/entrée pour lesquels le problème de l’arrêt est indécidable.
La preuve de Turing est assez simple et fonctionne par l’absurde. Supposons que l’on
ait un algorithme halt or not(A,i) prenant en entrée un algorithme A et une entrée i et
qui retourne vrai si A(i) termine, et faux si A(i) ne termine pas. On crée alors l’algorithme
suivant :
11. Pour plus de détails sur cette conjecture, allez voir la page [Link]
Conjecture_de_Collatz, ou la version anglaise un peu plus complète [Link]
Collatz_conjecture.
32 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 2. Récursivité
1 void halt_err(program A) {
2 if (halt_or_not(A,A)) {
3 while (1) {
4 printf("Boucle infinie\n");
5 }
6 }
7 return;
8 }
Cet algorithme halt err va donc terminer uniquement si halt or not(A,A) renvoie faux,
sinon, il part dans une boucle infinie. Maintenant si on appel halt err(halt err), le pro-
gramme ne termine que si halt or not(halt err,halt err) renvoie faux, mais s’il ter-
mine cela signifie que halt or not(halt err,halt err) devrait renvoyer vrai. De même,
si l’appel à halt or not(halt err,halt err) renvoie vrai, halt err(halt err) va tour-
ner indéfiniment, ce qui signifie que halt or not(halt err,halt err) aurait du renvoyer
faux. Nous avons donc une contradiction : un programme ne peut donc pas résoudre le
problème de l’arrêt pour tous les couples programme/entrée.
& M. Finiasz 33
Chapitre 3
Structures de Données
3.1 Tableaux
Les tableaux sont la structure de donnée la plus simple. Ils sont en général implémentés
nativement dans la majorité des langages de programmation et sont donc simples à utiliser.
Un tableau représente une zone de mémoire consécutive d’un seul bloc (cf. Figure 3.1)
ce qui présente à la fois des avantages et des inconvénients :
– la mémoire est en un seul bloc consécutif avec des éléments de taille constante à
l’intérieur (la taille de chaque élément est défini par le type du tableau), donc il est
très facile d’accéder au ième élément du tableau. L’instruction tab[i] se contente de
prendre l’adresse mémoire sur laquelle pointe tab et d’y ajouter i fois la taille d’un
élément.
– il faut fixer la taille du tableau avant de commencer à l’utiliser. Les systèmes d’ex-
ploitation modernes gardent une trace des processus auxquels les différentes zones de
mémoire appartiennent : si un processus va écrire dans une zone mémoire qui ne lui
appartient pas (une zone que le noyau ne lui a pas alloué) il y a une erreur de segmen-
tation. Quand vous programmez, avant d’écrire (ou de lire) à la case i d’un tableau
il est nécessaire de vérifier que i est plus petit que la taille allouée au tableau (car le
compilateur ne le vérifiera pas pour vous).
35
§ 3.1 Tableaux ENSTA – cours IN101
Mémoire
tab espace
non alloué
1 int i;
2 int** tab2;
3 tab2 = (int** ) malloc(4*sizeof(int* ));
4 for (i=0; i<4; i++) {
5 tab2[i] = (int* ) malloc(4*sizeof(int ));
6 }
7 /* ou en utilisant new */
8 tab2 = new int* [4];
9 for (i=0; i<4; i++) {
10 tab2[i] = new int [4];
11 }
36 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 3. Structures de Données
tab[2][0]
tab[2][1]
tab[2][2]
tab[2][3]
tab[3][0]
tab[3][1]
tab[3][2]
tab[3][3]
Mémoire
tab[0]
tab[1]
tab[2]
tab[3]
tab
tab[1][0]
tab[1][1]
tab[1][2]
tab[1][3]
tab[0][0]
tab[0][1]
tab[0][2]
tab[0][3]
L’utilisation de malloc (ou new en C++) est donc beaucoup plus lourde, d’autant plus
que la mémoire allouée ne sera pas libérée d’elle même et l’utilisation de la commande free
(ou delete en C++) sera nécessaire, mais présente deux avantages :
– le code est beaucoup plus proche de ce qui se passe réellement en mémoire (surtout
dans le cas à deux dimensions) ce qui permet de mieux se rendre compte des opérations
réellement effectuées. En particulier, une commande qui paraı̂t simple comme int
tab[1000][1000][1000]; demande en réalité d’allouer un million de fois 1000 entiers,
ce qui est très long et occupe 4Go de mémoire.
– cela laisse beaucoup plus de souplesse pour les allocations en deux dimensions (ou plus) :
contrairement à la notation simplifiée, rien n’oblige à avoir des tableaux carrés !
Pour les cas simples, la notations [100] est donc suffisante, mais dès que cela devient
compliqué, l’utilisation de malloc devient nécessaire.
Exemple d’allocation non carrée. Le programme suivant permet de calculer tous les
coefficients binomiaux de façon récursive en utilisant la construction du triangle de Pascal.
La méthode récursive simple vue au chapitre précédent (cf. page 30) est très inefficace car
elle recalcule un grand nombre de fois les même coefficients. Pour l’améliorer, on utilise
un tableau bidimensionnel qui va servir de cache : chaque fois qu’un coefficient est calculé
on le met dans le tableau, et chaque fois que l’on a besoin d’un coefficient on regarde
d’abord dans le tableau avant de la calculer. C’est ce que l’on appelle la programmation
dynamique. Le point intéressant est que le tableau est triangulaire, et l’utilisation de malloc
(ou new) permet de ne pas allouer plus de mémoire que nécessaire (on gagne un facteur 2
sur l’occupation mémoire ici).
1 int** tab;
2
& M. Finiasz 37
§ 3.1 Tableaux ENSTA – cours IN101
5 if ((p==0) || (n==p) {
6 tab[n][p] = 1;
7 } else {
8 tab[n][p] = binomial(n-1,p) + binomial(n-1,p-1);
9 }
10 }
11 return tab[n][p];
12 }
13
14 int main(int argc, char** argv) {
15 int i;
16 tab = (int** ) malloc(33*sizeof(int* ));
17 for (i=0; i<34; i++) {
18 tab[i] = (int* ) calloc((i+1),sizeof(int ));
19 }
20 for (i=0; i<34; i++) {
21 binomial(33,i);
22 }
23
24 /* insérer ici les instructions qui
25 utilisent la table de binomiaux */
26
On utilise donc la variable globale tab comme table de cache et la fonction binomiale
est exactement la même qu’avant, mis à part qu’elle vérifie d’abord dans le cache si la
valeur a déjà été calculée, et qu’elle stocke la valeur avant de la retourner. On arrête ici
le calcul de binomiaux à n = 33 car au-delà les coefficients binomiaux sont trop grands
pour tenir dans un int de 32 bits. Notez ici l’utilisation de calloc qui alloue la mémoire
et l’initialise à 0, contrairement à malloc qui laisse la mémoire non initialisée. La fonction
calloc est donc plus lente, mais l’initialisation est nécessaire pour pouvoir utiliser la
technique de mise en cache. Cette technique de programmation dynamique est très utilisée
quand l’on veut programmer vite et efficacement un algorithme qui se décrit mieux de
façon récursive qu’itérative. Cela sera souvent le cas dans des problèmes combinatoires ou
de dénombrement.
38 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 3. Structures de Données
& M. Finiasz 39
§ 3.2 Listes chaı̂nées ENSTA – cours IN101
Mémoire
L
data data
data NULL
data
fois toute les t insertions. La complexité pour insérer n éléments est donc :
n
⌊t⌋ n n
∑ ( + 1)
K =n+ t×i≃t× t t
+ n = Θ(n2 ).
i=1
2
Donc en moyenne, la complexité de l’insertion d’un élément est Kn = Θ(n). C’est beaucoup
trop !
La bonne solution consiste à doubler la taille du tableau à chaque réallocation (ou la
multiplier par n’importe quelle constante plus grande que 2). Ainsi, pour insérer n éléments
dans le tableau il faudra avoir recopié une fois n2 éléments, le coup d’avant n4 , celui d’avant
n
8
... Au total la complexité est donc :
⌈log2 n⌉
∑
K =n+ 2i ≃ 2n = Θ(n).
i=0
Ce qui donne en moyenne une complexité par insertion de Kn = Θ(1). Il est donc possible
de conserver toutes les bonnes propriétés des tableaux et d’ajouter une taille variable sans
rien perdre sur les complexités asymptotiques.
La réallocation dynamique de tableau a donc un coût assez faible si elle est bien faite,
mais elle nécessite en revanche d’utiliser plus de mémoire que les autres structures de
données : un tableau contient toujours de l’espace alloué mais non utilisé, et la phase de
réallocation nécessite d’allouer en même temps le tableau de taille n et celui de taille n2 .
40 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 3. Structures de Données
Figure 3.3). Une liste est donc en fait simplement un pointeur vers le premier élément de
la liste et pour accéder à un autre élément, il suffit ensuite de suivre la chaı̂ne de pointeurs.
En général le dernier élément de la liste pointe vers NULL, ce qui signifie aussi qu’une liste
vide est simplement un pointeur vers NULL. En C, l’utilisation de liste nécessite la création
d’une structure correspondant à un nœud de la liste. Le type list en lui même doit ensuite
être défini comme un pointeur vers un nœud. Cela donne le code suivant :
1 struct cell {
2 /* insérer ici toutes les données que doit contenir un noeud */
3 cell* next;
4 };
5 typedef cell* list;
Insertion. L’insertion d’un élément à la suite d’un élément donné se fait en deux étapes
illustrées sur le dessin suivant :
On crée le nœud en lui donnant le bon nœud comme nœud suivant. Il suffit ensuite
de faire pointer l’élément après lequel on veut l’insérer vers ce nouvel élément. Le dessin
représente une insertion en milieu de liste, mais en général, l’ajout d’un élément à une liste
se fait toujours par le début : dans ce cas l’opération est la même, mais le premier élément
de liste est un simple pointeur (sans champ data).
& M. Finiasz 41
§ 3.2 Listes chaı̂nées ENSTA – cours IN101
data data
data data
data data
data data
data data
data data
Ici encore, le dessin représente une suppression en milieux de liste, mais le cas le plus
courant sera la suppression du premier élément d’une liste.
Parcours. Pour parcourir une liste on utilise un ≪ curseur ≫ qui pointe sur l’élément que
l’on est en train de regarder. On initialise le curseur sur le premier élément et on suit les
pointeurs jusqu’à arriver sur NULL. Il est important de ne jamais perdre le pointeur vers
le premier élément de la liste (sinon les éléments deviennent définitivement inaccessibles) :
c’est pour cela que l’on utilise une autre variable comme curseur. Voila le code C d’une
fonction qui recherche une valeur (passée en argument) dans une liste d’entiers, et remplace
toutes les occurrences de cette valeur par des 0.
Notons qu’ici, la liste L est passée en argument, ce qui crée automatiquement une nouvelle
variable locale à la fonction cleaner. Il n’était donc pas nécessaire de créer la variable cur.
Cela ayant de toute façon un coût négligeable par rapport à un appel de fonction, il n’est
pas gênant de prendre l’habitude de toujours avoir une variable dédiée pour le curseur.
42 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 3. Structures de Données
Mémoire L_end
L
data data
data NULL
NULL
Listes doublement chaı̂nées. Une liste doublement chaı̂née (cf. Figure 3.4) possède
en plus des listes simples un pointeur vers l’élément précédent dans la liste. Cela s’accom-
pagne aussi en général d’une deuxième variable L end pointant vers le dernier élément de
la liste et permet ainsi un parcours dans les deux sens et un ajout simple d’éléments en fin
de liste. Le seul surcoût est la présence du pointeur en plus qui ajoute quelques opérations
de plus à chaque insertion/suppression et qui occupe un peu d’espace mémoire.
Listes circulaires. Les listes circulaires sont des listes chaı̂nées (simplement ou dou-
blement) dont le dernier élément ne pointe pas vers NULL, mais vers le premier élément
(cf. Figure 3.5). Il n’y a donc plus de réelle notion de début et fin de liste, il y a juste
une position courante indiquée par un curseur. Le problème est qu’une telle liste ne peux
jamais être vide : afin de pouvoir gérer ce cas particulier il est nécessaire d’utiliser ce que
l’on appelle une sentinelle.
Sentinelles. Dans une liste, une sentinelle est un nœud particulier qui doit pouvoir être
reconnaissable en fonction du contenu de son champ data (une méthode classique est
d’ajouter un entier au champ data qui est non-nul uniquement pour la sentinelle) et qui
sert juste à simplifier la programmation de certaines listes mais ne représente pas un réel
élément de la liste. Une telle sentinelle peut avoir plusieurs usages :
& M. Finiasz 43
§ 3.3 Piles & Files ENSTA – cours IN101
Mémoire
cur
data data
data
data
– représenter une liste circulaire vide : il est possible de décider qu’une liste circulaire vide
sera représentée en mémoire par une liste circulaire à un seul élément (qui est donc son
propre successeur) qui serait une sentinelle.
– terminer une liste non-circulaire : il peut être pratique d’ajouter une sentinelle à la fin
de toute liste chaı̂née pour que lorsque la liste est vide des fonctions comme ≪ retourner
le premier élément ≫ ou ≪ retourner le dernier élément ≫ aient toujours quelque chose
à renvoyer. Dans la plupart des cas on peut leur demander de retourner NULL, mais une
sentinelle peut rendre un programme plus lisible.
On peut aussi envisager d’avoir plusieurs types de sentinelles, par exemple une qui mar-
querait un début de liste et une autre une fin de liste.
44 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 3. Structures de Données
1 list L;
2 void push(int n) {
3 cell* nw = (cell* ) malloc(sizeof(cell ));
4 nw->data = n;
5 nw->next = L;
6 L = nw;
7 }
8
9 int pop() {
10 int val;
11 cell* tmp;
12 /* on teste d’abord si la pile est vide */
13 if (L == NULL) {
14 return -1;
15 }
16 val = L->data;
17 tmp = L;
18 L = L->next;
19 free(tmp);
20 return val;
21 }
La fonction push ajoute un entier dans la pile et la fonction pop retourne le dernier entier
ajouté à la pile et le retire de la pile. Pour l’utilisateur qui n’utilise que les fonctions push
et pop, le fait que la pile est implémentée avec une liste L est transparent.
Exemples d’utilisation de piles. Les piles sont une structure de donnée très basique (il
n’y a que deux opérations possibles), mais elles sont utilisées très souvent en informatique.
& M. Finiasz 45
§ 3.3 Piles & Files ENSTA – cours IN101
1 list L, L_end;
2 void push(int n) {
3 cell* nw = (cell* ) malloc(sizeof(cell ));
4 nw->data = n;
5 nw->next = NULL;
6 /* si la file est vide, il faut mettre
7 à jour le premier et le dernier élément */
8 if (L == NULL) {
9 L = nw;
10 L_end = nw;
11 } else {
12 L_end->next = nw;
46 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 3. Structures de Données
13 L_end = nw;
14 }
15 }
16
17 int pop() {
18 int val;
19 cell* tmp;
20 /* on teste d’abord si la file est vide */
21 if (L == NULL) {
22 return -1;
23 }
24 val = L->data;
25 tmp = L;
26 L = L->next;
27 free(tmp);
28 return val;
29 }
Encore une fois, pour l’utilisateur de cette file, le fait que nous utilisons une liste est
transparent.
Exemples d’utilisation de files. Les files sont utilisées partout où l’on a des données
à traiter de façon asynchrone avec leur arrivée, mais où l’ordre de traitement de ces
données est important (sinon on préfère en général utiliser une pile qui est plus légère
à implémenter). C’est le cas par exemple pour le routage de paquets réseau : le routeur
reçoit des paquets qui sont mis dans une file au fur et à mesure qu’ils arrivent, le routeur
sort les paquets de cette file un par un et les traite en fonction de leur adresse de desti-
nation pour les renvoyer. La file sert dans ce cas de mémoire tampon et permet ainsi de
mieux utiliser les ressources du routeur : si le trafic d’entrée est irrégulier il suffit grâce au
tampon de pouvoir traiter un flux égal au débit d’entrée moyen, alors que sans tampon il
faudrait pouvoir traiter un flux égal au débit maximum en entrée.
& M. Finiasz 47
Chapitre 4
Recherche en table
Le problème auquel on s’intéresse dans ce chapitre est celui de la mise en œuvre in-
formatique d’un dictionnaire ; autrement dit, il s’agit d’étudier les méthodes permettant de
retrouver le plus rapidement possible une information en mémoire, accessible à partir d’une
clef (par exemple, trouver une définition à partir d’un mot). La méthode la plus naturelle
est l’implémentation par tableau (table à adressage direct : pour gérer un dictionnaire avec
des mots jusqu’à 30 lettres on crée une table assez grande pour contenir les 2630 mots
possibles, et on place les définitions dans la case correspondant à chaque mot), mais bien
que sa complexité temporelle soit optimale, cette méthode devient irréaliste en terme de
complexité spatiale dès lors que l’on doit gérer un grand nombre de clefs. Une deuxième
méthode consiste à ne considérer que les clefs effectivement présentes en table (recherche
séquentielle) : on obtient alors des performances analogues aux opérations de base sur les
listes chaı̂nées, la recherche d’un élément étant linéaire en la taille de la liste. Une approche
plus efficace est la recherche dichotomique, dans laquelle on utilise un tableau contenant les
clefs triées (comme c’est le cas dans un vrai dictionnaire) ; la complexité de l’opération de
recherche devient alors en log(n), est rend cette méthode très intéressante pour de gros fi-
chiers auxquels on accède souvent mais que l’on modifie peu. Enfin, une quatrième méthode
abordée est l’utilisation de tables de hachage, qui permet d’atteindre en moyenne les perfor-
mances de l’adressage direct tout en s’affranchissant du nombre potentiellement très grand
de clefs possible.
4.1 Introduction
Un problème très fréquent en informatique est celui de la recherche de l’information
stockée en mémoire. Cette information est en général accessible via une clef. La consulta-
tion d’un annuaire électronique est l’exemple type d’une telle recherche. Un autre exemple
est celui d’un compilateur, qui doit gérer une table des symboles, dans laquelle les clefs sont
les identificateurs des donnés à traiter. Les fonctionnalités 1 d’un dictionnaire sont exacte-
ment celles qu’il convient de mettre en oeuvre lorsque l’on souhaite gérer l’information en
1. Pour la création, l’utilisation et la mise à jour.
49
§ 4.2 Table à adressage direct ENSTA – cours IN101
1 struct cell {
2 bool key_exists;
50 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 4. Recherche en table
3 type_info info;
4 };
5 cell* tab = (cell* ) malloc(m*sizeof(cell ));
& M. Finiasz 51
§ 4.3 Recherche séquentielle ENSTA – cours IN101
1 struct cell {
2 int key;
3 type_info info;
4 };
5 cell* tab; /* l’allocation doit ^
etre faite dans le main */
6 int p=0;
7
On peut également utiliser une liste chaı̂née à la place d’un tableau. Un avantage
est alors que l’on n’a plus de limitation sur la taille. Les complexités de recherche et de
suppression sont les mêmes que dans l’implémentation par tableau de taille n. L’insertion
conserve elle aussi sa complexité de Θ(1) sauf que les nouveaux éléments sont insérés au
début au lieu d’à la fin. La modification nécessite toujours un temps en Θ(n) (le coût d’une
recherche). Asymptotiquement les complexités sont les mêmes, mais dans la pratique la
recherche sera un peu plus lente (un parcours de tableau est plus rapide qu’un parcours
52 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 4. Recherche en table
de liste) et la suppression un peu plus rapide (on n’a pas à décaler tous les éléments du
tableau). Avec une liste, les opérations sont alors exactement les mêmes que celles décrites
dans le chapitre sur les listes.
& M. Finiasz 53
§ 4.4 Recherche dichotomique ENSTA – cours IN101
Lorsque n n’est pas une puissance de deux, notons k l’entier tel que 2k ≤ n < 2k+1 . Cn
est une fonction croissante, donc
C2k ≤ Cn ≤ C2k+1 ,
val − tab[p]
q=p+ (r − p).
tab[r − 1] − tab[p]
Il s’avère que, dans le cas où les clefs ont une distribution aléatoire, cette amélioration
réduit drastiquement le coût de la recherche, comme le montre le résultat suivant (que nous
admettrons) :
Attention, ce résultat très intéressant n’est valable que si les clef sont réparties uni-
formément dans l’ensemble des clefs possibles. Une distribution biaisée peut grandement
dégrader cette complexité.
54 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 4. Recherche en table
Mémoire
m key info
tab
key info
key info
key info
= pointeur vers NULL
& M. Finiasz 55
§ 4.5 Tables de hachage ENSTA – cours IN101
Dans le pire cas, les n clefs ont toute la même valeur hachée, et on retombe sur la
recherche séquentielle sur une liste de taille n, en Θ(n) (suppression en Θ(n) également).
En revanche, si la fonction de hachage répartit les n clefs uniformément dans l’intervalle
[0, m − 1] - on parle alors de hachage uniforme simple - chaque liste sera de taille ρ en
moyenne, et donc la recherche d’un élément (comme sa suppression) nécessitera au plus
ρ+1 comparaisons. Une estimation préalable de la valeur de n permet alors de dimensionner
la table de façon à avoir une recherche en temps constant (en choisissant m = O(n)). La
complexité spatiale de cette méthode est en Θ(m+n), pour stocker la table et les n éléments
de liste.
h(c) = c mod m
est rapide et possède de bonnes propriétés de répartition statistique dès lors que m est un
nombre premier assez éloigné d’une puissance de 2 ; (si m = 2ℓ , cela revient à prendre ℓ
bits de la clef).
Une autre fonction fréquemment utilisée est la fonction
√
où x est une constante réelle appartenant à ]0, 1[. Le choix de x = 5−1
2
conduit à de bonnes
performances en pratique (hachage de Fibonacci), mais le passage par des calculs flottants
rend le hachage un peu plus lent.
56 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 4. Recherche en table
Note : les complexités ci-dessus sont celles dans le pire cas, sauf celles suivies de ∗ pour
lesquelles il s’agit du cas moyen.
On constate qu’une table de hachage de taille bien adaptée et munie d’une bonne
fonction de hachage peut donc être très efficace. Cependant cela demande d’avoir une
bonne idée de la quantité de données à gérer à l’avance.
& M. Finiasz 57
Chapitre 5
Arbres
5.1 Préliminaires
5.1.1 Définitions et terminologie
Un arbre est une collection (éventuellement vide) de nœuds et d’arêtes assujettis à
certaines conditions : un nœud peut porter un nom et un certain nombre d’informations
pertinentes (les données contenues dans l’arbre) ; une arête est un lien entre deux nœuds.
Une branche (ou chemin) de l’arbre est une suite de nœuds distincts, dans laquelle deux
nœuds successifs sont reliés par une arête. La longueur d’une branche est le nombre de ses
arêtes (ou encore le nombre de nœuds moins un). Un nœud est spécifiquement désigné
comme étant la racine de l’arbre 1 . La propriété fondamentale définissant un arbre est
alors que tout nœud est relié à la racine par une et une seule branche 2 (i.e. il existe un
unique chemin d’un nœud donné à la racine).
Comme on peut le voir sur la Figure 5.1, chaque nœud possède un lien (descendant,
selon la représentation graphique) vers chacun de ses fils (ou descendants), éventuellement
un lien vers le vide, ou pas de lien si le nœud n’a pas de fils ; inversement, tout nœud - sauf
1. Si la racine n’est pas spécifiée, on parle plutôt d’arborescence.
2. Dans le cas ou certains nœuds sont reliés par plus d’une branche (ou pas de branche du tout) à la
racine on parle alors de graphe.
59
§ 5.1 Préliminaires ENSTA – cours IN101
Racine = Nœud
= Arête
Nœuds
internes
es
ill
u
Fe
la racine - possède un père (ou ancêtre) et un seul, qui est le nœud immédiatement au-
dessus de lui dans la représentation graphique. Les nœuds sans descendance sont appelés
feuilles, les nœuds avec descendance étant des nœuds internes. Tout nœud est la racine du
sous-arbre constitué par sa descendance et lui-même.
Voici une liste du vocabulaire couramment utilisé pour les arbres :
– Une clef est une information contenue dans un nœud.
– Un ensemble d’arbres est une forêt.
– Un arbre est ordonné si l’ordre des fils de chacun de ses nœuds est spécifié. C’est
généralement le cas dans les arbres que l’on va considérer dans la suite.
– Un arbre est de plus numéroté si chacun de ses fils est numéroté par un entier strictement
positif (deux nœuds ne possédant pas le même numéro).
– Les nœuds d’un arbre se répartissent en niveaux. Le niveau d’un nœud est la longueur
de la branche qui le relie à la racine.
– La hauteur d’un arbre est le niveau maximum de l’arbre (i.e. plus grande distance d’un
nœud à la racine).
– La longueur totale d’un arbre est la somme des longueurs de tous les chemins menant
des nœuds à la racine.
– Le degré d’un nœud est le nombre de fils qu’il possède.
– L’arité d’un arbre est le degré maximal de ses nœuds.
– Lorsque l’on a un arbre dont les fils de chaque nœud sont numérotés par des entiers tous
compris dans l’intervalle [1, . . . , k], on parle d’arbre k-aire. Un arbre k-aire est donc un
arbre numéroté, d’arité inférieure ou égale à k.
– Comme avec les sentinelles pour les listes, on peut définir un type particulier de nœud
dit nœud vide : c’est un nœud ≪ factice ≫ dans le sens où il ne contient pas d’information ;
il peut juste servir à remplir la descendance des nœuds qui contiennent moins de k fils.
– L’exemple le plus important d’arbre m-aire est l’arbre binaire. Chaque nœud possède
deux fils et on parle alors de fils gauche et de fils droit d’un nœud interne. On peut
60 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
également définir la notion de fils gauche et fils droit d’une feuille : il suffit de représenter
chacun d’eux par le nœud vide. De cette manière, tout nœud non vide peut être
considéré comme un nœud interne.
– Un arbre binaire est complet si les nœuds remplissent complètement tous les niveaux,
sauf éventuellement le dernier, pour lequel les nœuds apparaissent alors tous le plus à
gauche possible (notez que l’arbre binaire de hauteur 0 est complet).
& M. Finiasz 61
§ 5.1 Préliminaires ENSTA – cours IN101
Pour un arbre binaire complet de hauteur h contenant N nœuds, tous les niveaux (sauf
le dernier sont complètement remplis. On a donc N ≥ 1 + 2 + . . . + 2h−1 = 2h − 1 , i.e.
log2 (N + 1) ≥ h. La hauteur d’un arbre binaire complet à N nœuds est donc toujours de
l’ordre de log2 (N ).
1 struct node {
2 int key;
3 type_info info;
4 node* left;
5 node* right;
6 };
7 typedef node* binary_tree;
Arbres k-aires. On peut construire un arbre k-aire de façon très similaire (où k est un
entier fixé une fois pour toute dans le programme) :
1 struct node {
2 int key;
3 type_info info;
4 node sons[k];
5 };
6 typedef node* k_ary_tree;
Arbres généraux. Dans le cas d’un arbre général il n’y a pas de limite sur le nombre
de fils d’un nœud. Il faut donc utiliser une structure dynamique pour stocker tous les fils
d’un même nœud. Pour cela on utilise une liste chaı̂née. Chaque nœud contient donc un
pointeur vers son fils le plus à gauche (le premier élément de la liste), qui lui contient un
pointeur vers sont frère juste à sa droite (la queue de la liste). Chaque nœud contient donc
un pointeur vers un fils et un pointeur vers un frère. On obtient une structure de la forme :
1 struct node {
2 int key;
3 type_info info;
4 node* brother;
5 node* son;
6 };
7 typedef node* tree;
62 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
= Nœud
= Arête
= Fils
= Frère
= NULL
Figure 5.2 – Arbre général : on accède aux fils d’un nœud en suivant une liste chaı̂née.
Dans cette représentation, on voit que tout nœud possède deux liens : elle est donc
identique à la représentation d’un arbre binaire. Ainsi, on peut voir un arbre quelconque
comme un arbre binaire (avec, pour tout nœud, le lien gauche pointant sur son fils le plus
à gauche, le lien droit vers son frère immédiatement à droite). Nous verrons cependant les
modifications à apporter lors du parcours des arbres généraux.
Remonter dans un arbre
Dans les applications où il est seulement nécessaire de remonter dans l’arbre, et pas de
descendre, la représentation par lien-père d’un arbre est suffisante. Cette représentation
consiste à stocker, pour chaque nœud, un lien vers son père. On peut alors utiliser deux
tableaux pour représenter un tel arbre : on prend soin d’étiqueter les nœuds de l’arbre
au préalable (de 1 à k s’il y a k nœuds). Le premier tableau tab info de taille k
contiendra l’information contenue dans chaque nœud (tab info[i] = info du nœud
i), le deuxième tableau tab father de taille k lui aussi contiendra les liens père, de
telle sorte que tab father[i] = clef du père du nœud i. L’information relative au père
du nœud i se trouve alors dans tab info[tab father[i]].
Lorsqu’on doit juste remonter dans l’arbre, une représentation par tableaux est donc
suffisante. On pourrait aussi utiliser une liste de tous les nœud dans laquelle chaque
nœud contiendrait en plus un lien vers son père.
& M. Finiasz 63
§ 5.2 Utilisation des arbres ENSTA – cours IN101
4 3
Figure 5.3 – Un exemple d’arbre d’évaluation, correspondant à l’expression (4+3)×2.
1 void preorder_traversal(tree A) {
2 if (A != NULL) {
3 printf("%d ", A->key);
3. Plus le parcours par niveau, appelé également parcours en largeur (i.e. parcours du haut vers le bas,
en visitant tous les nœuds d’un même niveau de gauche à droite avant de passer au niveau suivant. Ce
parcours n’est pas récursif, et est utilisé par exemple dans la structure de tas (cf. section 5.2.3), mais ne
permet pas d’évaluer une expression.
64 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
4 preorder_traversal(A->left);
5 preorder_traversal(A->right);
6 }
7 }
1 void inorder_traversal(tree A) {
2 if (A != NULL) {
3 printf("(");
4 inorder_traversal(A->left);
5 printf("%d", A->key);
6 inorder_traversal(A->right);
7 printf(")");
8 }
9 }
On est obligé d’ajouter des parenthèses pour lever les ambiguı̈tés, ce qui rend l’expression
un peu plus lourde : l’algorithme affichera ((4) + (3)) × (2).
1 void postorder_traversal(tree A) {
2 if (A != NULL) {
3 postorder_traversal(A->left);
4 postorder_traversal(A->right);
5 printf("%d ", A->key);
6 }
7 }
Complexité. Pour les trois parcours ci-dessus, il est clair que l’on visite une fois chaque
nœud, donc n appels récursifs pour un arbre à n nœuds, et ce quel que soit le parcours
considéré. La complexité de l’opération de parcours est donc en Θ(n).
Cas des arbres généraux. Les parcours préfixes et postfixes sont aussi bien définis
pour les arbres quelconques. Dans ce cas, la loi de parcours préfixe devient : visiter la
racine, puis chacun des sous-arbres ; la loi de parcours postfixe devient : visiter chacun des
& M. Finiasz 65
§ 5.2 Utilisation des arbres ENSTA – cours IN101
sous-arbres, puis la racine. Le parcours infixe ne peut en revanche pas être bien défini si le
nombre de fils de chaque nœud est variable.
Notons aussi que le parcours postfixe d’un arbre généralisé est équivalent au parcours
infixe de l’arbre binaire équivalent (comme vu sur la Figure 5.2) et que le parcours préfixe
d’un arbre généralisé est équivalant au parcours préfixe de l’arbre binaire équivalent.
Recherche dans un ABR. Pour chercher une clef dans un ABR on utilise naturellement
une méthode analogue à la recherche dichotomique dans une table. Pour trouver un nœud
de clef v, on commence par comparer v à la clef de la racine, notée ici r. Si v < r, alors
on se dirige vers le sous-arbre gauche de la racine. Si v = r, on a terminé, et on retourne
l’information associée à la racine. Si v > vr , on considère le sous-arbre droit de la racine.
On applique cette méthode récursivement sur les sous-arbres.
66 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
2 8
4 6 9
Il est à noter que la taille du sous-arbre courant diminue à chaque appel récursif. La
procédure s’arrête donc toujours : soit parce qu’un nœud de clef v a été trouvé, soit parce
qu’il n’existe pas de nœud ayant la clef v dans l’arbre, et le sous-arbre courant est alors
vide. Cela peut se programmer de façon récursive ou itérative :
& M. Finiasz 67
§ 5.2 Utilisation des arbres ENSTA – cours IN101
5 5 5
8
2 8 2 8 2 8
8
4 6 9 4 6 9 4 6 9
7 7 7 8
Figure 5.5 – Insertion d’un nœud dont la clef est déjà présente dans un ABR. Trois
emplacements sont possibles.
Insertion dans un ABR. L’insertion dans un ABR n’est pas une opération compliquée :
il suffit de faire attention à bien respecter l’ordre au moment de l’insertion. Pour cela, la
méthode la plus simple et de parcourir l’arbre de la même façon que pendant la recherche
d’une clef et lorsque l’on atteint une feuille, on peut insérer le nouveau nœud, soit à gauche,
soit à droite de cette feuille, selon la valeur de la clef. On est alors certain de conserver
l’ordre dans l’ABR. Cette technique fonctionne bien quand on veut insérer un nœud dont
la clef n’est pas présente dans l’ABR. Si la clef est déjà présente deux choix sont possibles
(cf. Figure 5.5) : soit on dédouble le nœud en insérant le nouveau nœud juste à côté de
celui qui possède la même clef (en dessus ou en dessous), soit on descend quand même
jusqu’à une feuille dans le sous-arbre droit du nœud.
Encore une fois, l’insertion peut se programmer soit de façon récursive, soit de façon
itérative. Dans les deux cas, on choisit d’insérer un nœud déjà présent dans l’arbre comme
s’il n’était pas présent : on l’insère comme fils d’une feuille (troisième possibilité dans la
Figure 5.5).
Notons que afin de pouvoir modifier l’arbre A (il n’est nécessaire de le modifier que quand
l’arbre est initialement vide) il est nécessaire de le passer en argument par pointeur. De ce
fait, les appels récursifs utilisent une écriture un peu lourde &((*A)->left) :
– on commence par prendre l’arbre *A.
68 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
Dans cette version itérative, on retrouve l’écriture un peu lourde de la version récursive
avec cur = &((*cur)->left). On pourrait être tenté de remplacer cette ligne par la ligne
(*cur) = (*cur)->left, mais cela ne ferait pas ce qu’il faut ! On peut voir la différence
entre ces deux commandes sur la Figure 5.6.
& M. Finiasz 69
§ 5.2 Utilisation des arbres ENSTA – cours IN101
État initial
A node** cur = A;
A
key
*A key
key
*A
key
cur
cur
key key
*A *A
key key
cur cur
A [Link] A [Link]
key key
*A *A
key key
cur cur
70 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
5 5 6
sous-a
rb
2 8 re 2 8 2 8
dr
o
it
4 6 9 4 6 9 4 7 9
7 7
successeur
& M. Finiasz 71
§ 5.2 Utilisation des arbres ENSTA – cours IN101
et de suppression, sont en Θ(h) : pour les version récursives, chaque appel fait descendre
d’un niveau dans l’arbre et pour les version itératives chaque étape de la boucle while fait
aussi descendre d’un niveau. Pour calculer la complexité, il faut donc connaı̂tre la valeur
de h.
Il existe des arbres de taille n et de hauteur n−1 : ce sont les arbres dont tous les nœuds
ont au plus un fils non vide. Ce type d’arbre est alors équivalent à une liste, et les opérations
ci-dessus ont donc une complexité analogue à celle de la recherche séquentielle (i.e. en
Θ(h) = Θ(n)). Noter que l’on obtient ce genre de configuration lorsque l’on doit insérer n
clefs dans l’ordre (croissant ou décroissant), ou bien par exemple les lettres A,Z,B,Y,C,X,...
dans cet ordre.
En revanche, dans le cas le plus favorable, i.e. celui d’un arbre complet où tous les
niveaux sont remplis, l’arbre a une hauteur environ égale 4 à log2 (n). Dans ce cas, les trois
opérations ci-dessus ont alors une complexité en Θ(h) = Θ(log2 (n)).
La question est maintenant de savoir ce qu’il se passe en moyenne. Si les clefs sont
insérées de manière aléatoire, on a le résultat suivant :
Proposition 5.2.1. La hauteur moyenne d’un arbre binaire de recherche construit alé-
atoirement à partir de n clefs distinctes est en Θ(log2 (n)).
Ainsi, on a un comportement en moyenne qui est logarithmique en le nombre de nœuds
de l’arbre (et donc proche du cas le meilleur), ce qui rend les ABR bien adaptés pour
l’implémentation de dictionnaires. En outre, afin de ne pas ≪ tomber ≫ dans le pire cas,
on dispose de méthodes de rééquilibrage d’arbres pour rester le plus près possible de la
configuration d’arbre complet (cf. section 5.3).
Tri par Arbre Binaire de Recherche
Comme nous l’avons vu, le parcours infixe d’un ABR affiche les clefs de l’arbre dans
l’ordre croissant. Ainsi, une méthode de tri se déduit naturellement de cette structure
de données :
– Soient n entiers à trier
– Construire l’ABR dont les nœuds ont pour clefs ces entiers
– Imprimer un parcours infixe de cet ABR.
La complexité de cette méthode de tri est en Θ(n log2 (n)) (Θ(nlog2 (n)) pour l’insertion
de n clefs, chaque insertion se faisant en Θ(log2 (n)), plus Θ(n) pour le parcours infixe)
en moyenne et dans le cas le plus favorable. Elle est en revanche en Θ(n2 ) dans le pire
cas (entiers dans l’ordre croissant ou décroissant).
72 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
55
0
24 18
1 2
14 11 16 9
3 4 5 6
3 12 6 1 7
7 8 9 10 11
55 24 18 14 11 16 9 3 12 6 1 7
nanceur d’un système d’exploitation qui doit exécuter en priorité les instructions venant
d’un processus de priorité élevée. Les éléments sortiront de cette file précisément selon leur
rang : l’élément de rang le plus élevé sortira en premier. Les primitives à implémenter pour
mettre en oeuvre et manipuler des files de priorité sont :
– une fonction d’initialisation d’une file (création d’une file vide),
– une fonction d’insertion d’un élément dans la file (avec son rang),
– une fonction qui retourne l’élément de plus haut rang,
– une fonction de suppression de l’élément de plus haut rang.
Le codage d’une file de priorité peut être réalisé au moyen d’une liste : alors l’insertion
se fait en temps constant, mais l’opération qui retourne ou supprime l’élément de plus
haut rang nécessite une recherche préalable de celui-ci, et se fait donc en Θ(n), où n est
le nombre d’éléments de la file. Autrement, on peut aussi choisir d’avoir une opération
d’insertion plus lente qui insère directement l’élément à la bonne position en fonction de
sa priorité (cette opération coûte alors Θ(n)) et dans ce cas les opérations de recherche et
suppression peuvent se faire en Θ(1).
Une méthode beaucoup plus efficace pour représenter une file de priorité est obtenue au
moyen d’un arbre binaire de structure particulière, appelé tas (heap en anglais) ou (arbre)
maximier. Un tas est un arbre binaire complet, qui possède en plus la propriété que la clef
de tout nœud est supérieure ou égale à celle de ses descendants. Un exemple de tas est
donné à la Figure 5.8.
La façon la plus naturelle d’implémenter un tas semble être d’utiliser une structure
d’arbre similaire à celle utilisées pour les ABR. Pourtant l’implémentation la plus efficace
utilise en fait un tableau. On commence par numéroter les nœuds de 0 (racine) à n-1
(nœud le plus à droite du dernier niveau) par un parcours par niveau. On place chaque
nœud dans la case du tableau tab correspondant à son numéro (cf. Figure 5.8) : la racine
dans tab[0], le fils gauche de la racine dans tab[1]... Avec cette numérotation le père du
nœud i est le nœud ⌊ i−1 2
⌋, et les fils du nœud i sont les nœuds 2i + 1 (fils gauche) et 2i + 2
(fils droit). La propriété de maximier (ordre sur les clefs) se traduit sur le tableau par :
& M. Finiasz 73
§ 5.2 Utilisation des arbres ENSTA – cours IN101
Initialisation. Pour coder un tas avec un tableau il faut au moins trois variables : le
tableau tab où ranger les nœuds, le nombre maximum max de nœuds que l’on peut mettre
dans ce tas, et le nombre de nœuds déjà présents n. Le plus simple est de coder cela au
moyen de la structure suivante :
1 struct heap {
2 int max;
3 int n;
4 int* tab;
5 };
Pour créer un tas (vide) de taille maximale m donnée en paramètre, on crée un objet de
type tas pour lequel tab est un tableau fraı̂chement alloué :
1 heap* init_heap(int m) {
2 heap* h = (heap* ) malloc(sizeof(heap ));
3 h->max = m;
4 h->n = 0;
5 h->tab = (int* ) malloc(m*sizeof(int ));
6 return h;
7 }
74 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
Insertion du nœud 43
55 55 55
24 18 24 18 24 43
14 11 16 9 14 11 43 9 14 11 18 9
3 12 6 1 7 43 3 12 6 1 7 16 3 12 6 1 7 16
55 16
24 43 24 43
14 11 18 9 14 11 18 9
3 12 6 1 7 16 3 12 6 1 7
43 43
24 16 24 18
14 11 18 9 14 11 16 9
3 12 6 1 7 3 12 6 1 7
placer ce nouveau nœud au bon endroit : pour cela, on compare v avec la clef de son nœud
père. Si v est supérieure à celle-ci, on permute ces deux clefs. On recommence jusqu’à ce
que v soit inférieure ou égale à la clef de son nœud père ou que v soit à la racine de l’arbre
(en cas 0 de tab). Les étapes d’une insertion sont représentées sur la Figure 5.9.
& M. Finiasz 75
§ 5.2 Utilisation des arbres ENSTA – cours IN101
Suppression. Pour supprimer la clef contenue dans la racine, on remplace celle-ci par
la clef (notée v) du nœud situé au niveau de profondeur le plus élevé et le plus à droite
possible (le dernier nœud du tableau), nœud que l’on supprime alors aisément étant donné
qu’il n’a pas de fils. Ensuite, pour rétablir l’ordre sur les clefs de l’arbre, i.e. mettre v à
la bonne place, on effectue une ≪ descente ≫ dans l’arbre : on compare v aux clefs des fils
gauche et droit de la racine et si v est inférieure ou égale à au moins une de ces deux clefs,
on permute v avec la plus grande des deux clefs. On recommence cette opération jusqu’à
ce que v soit supérieure ou égale à la clef de chacun de ses nœuds fils, ou jusqu’à arriver à
une feuille. Les étapes d’une suppression sont représentées sur la Figure 5.9.
Voici comment peut se programmer l’opération de suppression qui renvoie au passage
l’élément le plus grand du tas que l’on vient de supprimer.
1 int heap_del(heap* h) {
2 if (h->n == 0) {
3 printf("Erreur : le tas est vide.");
4 return -1;
5 }
6 /* on sauvegarde le max pour le retourner à la fin */
7 int max = h->tab[0];
8 int i = 0;
9 bool cont = true;
10 h->n--;
11 /* on met la clef du dernier noeud à la racine */
12 h->tab[0] = h->tab[h->n];
13 while (cont) {
14 if (2*i+2 > h->n) {
15 /* si le noeud i n’a pas de fils */
16 cont = false;
17 } else if (2*i+2 == h->n) {
18 /* si le noeud i a un seul fils (gauche)
19 on inverse les deux si nécessaire */
20 if (h->tab[i] < h->tab[2*i+1]) {
21 swap(&(h->tab[i]),&(h->tab[2*i+1]));
22 }
23 cont = false;
24 } else {
25 /* si le noeud i a deux fils
26 on regarde si l’un des deux est plus grand */
27 if ((h->tab[i] < h->tab[2*i+1]) ||
28 (h->tab[i] < h->tab[2*i+2])) {
29 /* on cherche le fils le plus grand */
30 int greatest;
31 if (h->tab[2*i+1] > h->tab[2*i+2]) {
32 greatest = 2*i+1;
33 } else {
34 greatest = 2*i+2;
35 }
36 /* on inverse et on continue la boucle */
37 swap(&(h->tab[i]),&(h->tab[greatest]));
76 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
38 i = greatest;
39 } else {
40 cont = false;
41 }
42 }
43 }
44 return max;
45 }
46
47 void swap(int* a, int* b) {
48 int tmp = (*a);
49 (*a)=(*b);
50 (*b)=tmp;
51 }
La fonction swap permet d’échanger deux cases du tableau (ou deux entiers situés
n’importe où dans la mémoire). On est obligé de passer en arguments des pointeurs vers
les entiers à échanger car si l’on passe directement les entiers, ils seront recopiés dans des
variables locales de la fonction, et ce sont ces variable locale qui seront échangées, les entiers
d’origine restant bien tranquillement à leur place.
Complexité des opérations sur les tas. Comme souvent dans les arbres, la complexité
des différentes opération dépend essentiellement de la hauteur totale de l’arbre. Ici, avec
les tas, nous avons de plus des arbres complets et donc la hauteur d’un tas et toujours
logarithmique en son nombre de nœuds : h = Θ(log(n)).
Les boucles while des opérations d’insertion ou de suppression sont exécutées au maxi-
mum un nombre de fois égal à la hauteur du tas. Chacune de ces exécution contenant un
nombre constant d’opération la complexité total des opérations d’insertion et de suppres-
sion est donc Θ(h) = Θ(log(n)).
& M. Finiasz 77
§ 5.3 Arbres équilibrés ENSTA – cours IN101
Autrement dit, on remplace le nœud racine x par le nœud y, le fils gauche du nœud y
pointe sur le nœud x, son fils droit (inchangé) pointe sur le sous-arbre Z, et le fils droit
du nœud x est mis à pointer sur le sous-arbre Y . Cette opération est représentée sur la
Figure 5.10.
La rotation droite est l’opération inverse :
78 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
x y
y x
X Z
Y Y
X
y x
x y
X Z
Y Y
X
On remplace le nœud racine y par le nœud x, le fils gauche du nœud x (inchangé) pointe
sur le sous-arbre X, son fils droit pointe sur le nœud y, et le fils gauche du nœud y est mis
à pointer sur le sous-arbre Y .
Utilisation des rotations. Le but des rotations est de pouvoir rééquilibrer un ABR.
On opère donc une rotation gauche lorsque l’arbre est ≪ déséquilibré à droite ≫, i.e. son
sous-arbre droit est plus haut que son sous-arbre gauche. On opère une rotation droite dans
le cas contraire. Il est aisé de vérifier que les rotations préservent la condition sur l’ordre
des clefs d’un ABR. On a alors :
Proposition 5.3.1. Si A est un ABR, et si la rotation gauche (resp. droite) est définie
sur A, alors G(A) (resp. D(A)) est encore un ABR.
On peut également définir des doubles rotations (illustrées sur la Figure 5.11) comme
suit : la rotation gauche-droite associe à l’arbre A = (x, Ag , Ad ), l’arbre D(x, G(Ag ), Ad ).
De manière analogue, la rotation droite-gauche associe à l’arbre A = (x, Ag , Ad ), l’arbre
G(x, Ag , D(Ad )). Ces opérations préservent également l’ordre des ABR. Une propriété im-
portante des rotations et doubles rotations est qu’elles s’implémentent en temps constant :
en effet, lorsqu’un ABR est représenté par une structure de données du type binary tree
définie plus haut dans ce chapitre, une rotation consiste essentiellement en la mise à jour
d’un nombre fixé (i.e. indépendant du nombre de nœuds de l’arbre ou de sa hauteur) de
pointeurs.
& M. Finiasz 79
§ 5.3 Arbres équilibrés ENSTA – cours IN101
Rotation gauche-droite
x
T
Z x
X
Y z
Z T
z
Y
X y x
Z
Y
X T
De manière informelle, un ABR est un arbre AVL si, pour tout nœud de l’arbre, les
hauteurs de ses sous-arbres gauche et droit diffèrent d’au plus 1. Plus précisément, posons
δ(A) = 0 si A est l’arbre vide, et dans le cas général :
80 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
Soit maintenant N (h), le nombre minimum de nœuds d’un arbre AVL de hauteur h.
On a N (−1) = 0, N (0) = 1, N (1) = 2, et, pour h ≥ 2,
N (h) = 1 + N (h − 1) + N (h − 2).
En effet, si l’arbre est de hauteur h, l’un (au moins) de ses sous arbre est de hauteur h − 1.
Plus le deuxième sous arbre est petit, moins il aura de nœud, mais la propriété d’AVL fait
qu’il ne peut pas être de hauteur plus petite que h − 2. Donc l’arbre AVL de hauteur h
contenant le moins de nœuds est constitué d’un nœud racine et de deux sous-arbres AVL
de nombre de nœuds minimum, l’un de hauteur h − 1, l’autre de hauteur h − 2.
Posons alors F (h) = N (h) + 1. On a F (0) = 2, F (1) = 3, et, pour h ≥ 2,
F (h) = F (h − 1) + F (h − 2),
donc F (h) = Fh+3 , où Fk est le k-ième nombre de Fibonacci. Pour tout arbre AVL à n
sommets et de hauteur h, on a par conséquent :
1 1
n + 1 ≥ F (h) = √ (ϕh+3 − (1 − ϕ)h+3 ) > √ ϕh+3 − 1,
5 5
√
avec ϕ = (1 + 5)/2. D’où
log2 (n + 2) √ 1
h+3< + logϕ ( 5) ≤ log2 (n + 2) + 2.
log2 (ϕ) log2 (ϕ)
Par exemple, un arbre AVL à 100 000 nœuds a une hauteur comprise entre 17 et 25. Le
nombre de comparaisons nécessaires à une recherche, insertion ou suppression dans un tel
arbre sera alors de cet ordre.
Arbres de Fibonacci
La borne supérieure de la proposition précédente est essentiellement atteinte pour les
arbres de Fibonacci définis comme suit : Φ(0) est l’arbre vide, Φ(1) est réduit à une
feuille, et, pour k ≥ 2, l’arbre Φk+2 a un sous-arbre gauche égal à Φk+1 , et un sous-arbre
doit égal à Φk . La hauteur de Φk est k − 1, et Φk possède Fk+2 nœuds.
L’implémentation des AVL est analogue à celle des ABR, à ceci près que l’on rajoute à
la structure binary tree un champ qui contient la hauteur de l’arbre dont la racine est le
nœud courant. Cette modification rend cependant le opération d’insertion et de suppression
un peu plus compliquées : il est nécessaire de remettre à jour ces hauteurs chaque fois que
cela est nécessaire.
& M. Finiasz 81
§ 5.3 Arbres équilibrés ENSTA – cours IN101
h
h+2
h+1
h D
Gg
Gd Gd D
Gg
G
h h
h+2
D h+2 D
h+1
h
Gg Gd
Gg
G G
à jour les hauteurs de tous les sous-arbres (dans une version récursive de l’insertion, cela
se fait aisément en abandonnant la récursion terminale et en remettant à jour les hauteur
juste après l’appel récursif pour l’insertion).
Toutefois, cette opération peut déséquilibrer l’arbre, i.e. l’arbre résultant n’est plus
AVL. Pour rétablir la propriété sur les hauteurs, il suffit de rééquilibrer l’arbre par des
rotations (ou doubles rotations) le long du chemin qui mène de la racine à la feuille où
s’est fait l’insertion. Dans la pratique, cela se fait juste après avoir remis à jour les hauteurs
des sous-arbres : si on constate un déséquilibre entre les deux fils de −2 ou 2 on effectue
une rotation, ou une double rotation. Supposons que le nœud x ait deux fils G et D et
qu’après insertion δ(x) = h(G) − h(D) = 2. Le sous-arbre G est donc plus haut que D.
Pour savoir si l’on doit faire un double rotation ou une rotation simple en x il faut regarder
les hauteurs des deux sous-arbres de G (notés Gg et Gd ). Si δ(G) > −1 alors on fait une
rotation droite sur x qui suffit à rééquilibrer l’arbre. Si δ(G) = h(Gg ) − h(Gd ) = −1 alors
il faut faire une double rotation : on commence par une rotation gauche sur G afin que
δ(G) > −1, puis on est ramené au cas précédent et une rotation droite sur x suffit. Cette
opération est illustrée sur la Figure 5.12.
Dans le cas où le sous-arbre D est le plus haut, il suffit de procéder de façon symétrique.
Il est important de noter qu’après une telle rotation (ou double rotation), l’arbre qui était
déséquilibré après l’insertion retrouve sa hauteur initiale. Donc la propriété d’AVL est
nécessairement préserver pour les nœuds qui se trouvent plus haut dans l’arbre.
Proposition 5.3.3. Après une insertion dans un arbre AVL, il suffit d’une seule rotation
ou double rotation pour rééquilibrer l’arbre. L’opération d’insertion/rééquilibrage dans un
AVL à n nœuds se réalise donc en O(log2 (n)).
82 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 5. Arbres
Conclusion sur les AVL. Les AVL sont donc des arbres binaires de recherches vérifiant
juste une propriété d’équilibrage supplémentaire. Le maintien de cette propriété n’aug-
mente pas le coût des opérations de recherche, d’insertion ou de suppression dans l’arbre,
mais permet en revanche de garantir que la hauteur de l’arbre AVL reste toujours lo-
garithmique en son nombre de nœuds. Cette structure est donc un peu plus complexe
à implémenter qu’un ABR classique mais n’offre que des avantages. D’autres structures
d’arbres équilibrés permettent d’obtenir des résultats similaires, mais à chaque fois, le
maintien de la propriété d’équilibrage rend les opération d’insertion/suppression un peu
plus lourdes à implémenter.
& M. Finiasz 83
Chapitre 6
Graphes
Nous nous intéressons ici essentiellement aux graphes orientés. Après un rappel de la
terminologie de base associée aux graphes et des principales représentations de ceux-ci, nous
présentons un algorithme testant l’existence de chemins, qui nous conduit à la notion de
fermeture transitive. Nous nous intéressons ensuite aux parcours de graphes : le parcours en
largeur est de nature itérative, et nous permettra d’introduire la notion d’arborescence des
plus courts chemins ; le parcours en profondeur - essentiellement récursif - admet plusieurs
applications, parmi lesquelles le tri topologique. Nous terminons par le calcul de chemins
optimaux dans un graphe (algorithme de Aho-Hopcroft-Ullman).
5 1
8 3
7
0
6
2
85
§ 6.1 Définitions et terminologie ENSTA – cours IN101
5 1
8 T 3
7
0
B(T)
Arc du graphe
6
2 Arc du cocycle de T
y. Les sommets x et y sont dits adjacents. Si x = y, l’arc (x, x) est appelé boucle.
Un arc représente une liaison orientée entre son origine et son extrémité. Lorsque l’orien-
tation des liaisons n’est pas une information pertinente, la notion de graphe non orienté
permet de s’en affranchir. Un graphe non orienté G = (S, A) est la donnée d’un ensemble
fini S de sommets, et d’une famille de paires de S dont les éléments sont appelés arêtes.
Étant donné un graphe orienté G, sa version non orientée est obtenue en supprimant les
boucles, et en remplaçant chaque arc restant (x, y) par la paire {x, y}.
Soit G = (S, A), un graphe orienté, et T , un sous-ensemble de S. L’ensemble
est appelé bordure de T (voir Figure 6.2). Si le sommet u appartient à B(T ), on dit aussi
que u est adjacent à T . Le graphe G est biparti s’il existe T ⊂ S, tel que A = ω(T ).
Définition 6.2. Soit G = (S, A), un graphe orienté. Un chemin f du graphe est une suite
d’arcs < a1 , . . . , ap >, telle que
org(ai+1 ) = ext(ai ).
L’origine du chemin f , notée aussi org(f ), est celle de son premier arc a1 , et son extrémité,
ext(f ), est celle de ap . La longueur du chemin est égale au nombre d’arcs qui le composent,
i.e. p. Un chemin f tel que org(f ) = ext(f ) est appelé un circuit.
Dans un graphe non orienté la terminologie est parfois un peu différente : un chemin
est appelé une chaı̂ne, et un circuit un cycle. Un graphe sans cycle est un graphe acyclique.
Soit G = (S, A), un graphe orienté, et soit x, un sommet de S. Un sommet y est un
ascendant (resp. descendant) de x, s’il existe un chemin de y à x (resp. de x à y).
86 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
5 1 0 1 2 3 4 5 6 7 8
4
0 0 0 0 1 0 0 1 1 0
1 0 0 0 0 0 0 0 0 0
8 3 2 0 0 0 0 0 0 0 1 0
3 0 0 0 0 1 0 1 0 0
7
0 4 0 1 0 0 0 0 0 0 0
5 0 0 0 0 0 0 0 0 1
6 0 0 0 0 0 0 0 0 0
7 0 0 1 0 0 0 0 0 0
6
2 8 1 1 0 0 0 0 0 0 0
Un graphe non orienté G est connexe si, pour tout couple de sommets, il existe une
chaı̂ne ayant ces deux sommets pour extrémités. Par extension, un graphe orienté est
connexe si sa version non orientée est connexe. La relation définie sur l’ensemble des som-
mets d’un graphe non orienté par x ≃ y s’il existe une chaı̂ne reliant x à y, est une relation
d’équivalence dont les classes sont appelées composantes connexes du graphe. Cette no-
tion est très similaire à la notion de composantes connexe en topologie : on regarde les
composantes connexe du dessin du graphe.
Soit G = (S, A), un graphe orienté. Notons ≡G , la relation d’équivalence définie sur S
par x ≡G y si x = y ou s’il existe un chemin joignant x à y et un chemin joignant y à x. Les
classes d’équivalence pour cette relation sont appelées composantes fortement connexes de
G.
& M. Finiasz 87
§ 6.2 Représentation des graphes ENSTA – cours IN101
5 1 tab
0 3 6 7
4 1
8 3 2 7
3 4 6
7 1
0 4
5 8
6
7 2
6
2 8 0 1
1 struct graph_mat {
2 int n;
3 int** mat;
4 };
5
Le coût en espace d’un tel codage de la matrice est clairement en Θ(n2 ). Ce codage de-
vient donc inutilisable dès que n dépasse quelques centaines de milliers. En outre, lorsque le
graphe est peu dense (i.e. le rapport |A|/|S|2 est petit et donc la matrice M est creuse) il est
trop coûteux. Cependant, la matrice d’adjacence possède de bonnes propriétés algébriques
qui nous serviront dans l’étude de l’existence de chemins (cf. section 6.3).
88 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
que tab[i] contienne la liste des successeurs du sommet i, pour tout 1 ≤ i ≤ n. Cette
représentation est en Θ(n + |A|), donc possède une complexité en espace bien meilleure
que la précédente (à noter que cette complexité est optimale d’un point de vue théorique).
Les structures de données correspondantes sont :
1 int n;
2 /* un sommet contient une valeur et tous ses successeurs */
3 struct vertex {
4 int num;
5 vertices_list* successors;
6 };
7 /* les successeurs forment une liste */
8 struct vertices_list {
9 vertex* vert;
10 vertices_list* next;
11 };
12 /* on alloue ensuite le tableau de sommets */
13 vertex* adjacancy = (vertex* ) malloc(n*sizeof(vertex ));
Dans le cas d’un graphe non orienté, on parle de liste de voisins, mais le codage reste
le même. Contrairement à la représentation par matrice d’adjacence, ici, les sommets du
graphe ont une véritable représentation et peuvent contenir d’autres données qu’un simple
entier. Avec la matrice d’adjacence, d’éventuelles données supplémentaires doivent être
stockées dans une structure annexe.
Preuve. On procède par récurrence sur p. Pour p = 1, le résultat est vrai puisqu’un chemin
de longueur 1 est un arc du graphe. Fixons un entier p ≥ 2, et supposons le théorème vrai
pour tout j ≤ p − 1. On a :
∑n
p p−1
Mi,j = Mi,k Mk,j .
k=1
& M. Finiasz 89
§ 6.3 Existence de chemins & fermeture transitive ENSTA – cours IN101
p−1
entre xi et xj est la somme, sur tout sommet ≪ intermédiaire ≫ xk , 1 ≤ k ≤ n, de Mi,k
multiplié par le nombre d’arcs (0 ou 1) reliant xk à xj .
La longueur d’un chemin entre deux sommets étant au plus n, on en déduit :
Où les fonction copy mat(A,B) et mult mat(&C,A,B) permettent respectivement de copier
la matrice A dans B et de sauver le produit des matrices A et B dans C. Cet algorithme
retourne ensuite -1 si aucun chemin n’existe entre les deux sommets et renvoies sinon la
longueur du plus court chemin entre les deux sommets.
Dans l’algorithme path exists, on peut avoir à effectuer n produits de matrices pour
connaı̂tre l’existence d’un chemin entre deux sommets donnés (c’est le cas pour trouver
un chemin de longueur n ou pour être certain qu’aucun chemin n’existe). Le produit de
deux matrices carrées d’ordre n requérant n3 opérations 1 , la complexité de recherche de
l’existence d’un chemin entre deux sommets par cet algorithme est en Θ(n4 ). C’est aussi
bien entendu la complexité du calcul de N .
90 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
tel que (x, y) ∈ A∗ si et seulement s’il existe un chemin dans G d’origine x et d’extrémité
y.
La matrice N définie précédemment calcule la fermeture transitive du graphe G. En
effet, la matrice d’adjacence M ∗ de G∗ est obtenue à partir de N en posant Mi,j ∗
= 0 si
∗ ∗
Ni,j = 0, Mi,j = 1 si Ni,j ̸= 0. Une fois calculée G , on peut répondre en temps constant à
la question de l’existence de chemins entre deux sommets x et y de G.
Exemple d’Application du Calcul de la Fermeture Transitive
Lors de la phase de compilation d’un programme, un graphe est associé à chaque fonc-
tion : c’est le graphe des dépendances (entre les variables) de la fonction et les sommets
de ce graphe représentent les variables, un arc entre deux sommets x et y indique que le
calcul de la valeur de x fait appel au calcul de la valeur de y. Le calcul de la fermeture
transitive de ce graphe permet alors d’obtenir toutes les variables intervenant dans le
calcul d’une variable donnée.
Si l’on considère l’itérée de l’action des Φxi sur A, on voit aisément que
Mieux :
Proposition 6.3.1. La fermeture transitive A∗ de G est donnée par
Preuve. Il reste à montrer A∗ ⊆ Φx1 (Φx2 (. . . (Φxn (A)) . . .)). Soit f , un chemin joignant deux
sommets x et y dans G. Il existe donc des sommets de G y1 , . . . yp tels que :
donc (x, y) ∈ Φy1 (Φy2 (. . . (Φyp (A)) . . .)). D’après la propriété ci-dessus, on peut permuter
l’ordre des yi dans cette écriture, donc on peut considérer par exemple que les yi sont
& M. Finiasz 91
§ 6.4 Parcours de graphes ENSTA – cours IN101
ordonnés suivant leurs numéros croissants. Pour (i1 , . . . , ik ) ⊆ {1, . . . , n}, avec, si 1 ≤
ℓ < j ≤ n, iℓ < ij , notons A(i1 ,...,ik ) = Φxi1 (Φxi2 (. . . (Φxik (A)) . . .)). On peut voir que la
suite A(i1 ,...,ik ) est croissante (i.e. A(i1 ,...,ik ) ⊆ A(j1 ,...,jℓ ) si (i1 , . . . , ik ) est une sous-suite de
(j1 , . . . , jℓ )). De plus, on vient de voir que, pour 1 ≤ p ≤ n, tout chemin de longueur p
joignant deux sommets x et y dans G appartient à un A(i1 ,...,ip ) , pour un p-uplet (i1 , . . . , ip )
d’éléments de {1, . . . , n}. Or, pour tout tel p-uplet, on a :
Donc tout chemin dans G (et tout arc dans A∗ ) appartient à Φx1 (Φx2 (. . . (Φxn (A)) . . .)).
Cette expression de la fermeture transitive d’un graphe donne lieu à l’algorithme de
Roy-Warshall suivant :
1 int** roy_warshall(graph_mat* G) {
2 int i,j,k;
3 int** M = (int** ) malloc(G->n*sizeof(int* ));
4 for (i=0; i<G->n; i++) {
5 M[i] = (int* ) malloc(G->n*sizeof(int ));
6 }
7 copy(G->mat,M);
8 for (k=0; k<G->n; k++) {
9 for (i=0; i<G->n; i++) {
10 for (j=0; j<G->n; j++) {
11 M[i][j] = M[i][j] || (M[i][k] && M[k][j]);
12 }
13 }
14 }
15 return M;
16 }
Dans cet algorithme, les deux boucles for internes implémentent exactement l’opération
Φxk (A) ; en effet, la matrice d’adjacence M ∗ de G∗ est construite à partir de celle de G
par : (xi , xj ) est un arc de G∗ si c’est un arc de G ou si (xi , xk ) et (xk , xj ) sont deux arcs de
G. C’est exactement ce qui est calculé au milieu de l’algorithme. Clairement, la complexité
de l’algorithme de Roy-Warshall est en Θ(n3 ) opérations booléennes.
Remarque : on obtient la fermeture réflexive-transitive de G en ajoutant à la matrice
d’adjacence de G∗ la matrice Identité d’ordre n.
92 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
6.4.1 Arborescences
Une arborescence (S, A, r) de racine r ∈ S est un graphe tel que, pour tout sommet x
de S, il existe un unique chemin d’origine r et d’extrémité x. La longueur d’un tel chemin
est appelé la profondeur de x dans l’arborescence. Dans une arborescence, tout sommet,
sauf la racine, admet un unique prédécesseur. On a donc |A| = |S| − 1. Par analogie avec
la terminologie employée pour les arbres, le prédécesseur d’un sommet est appelé son père,
les successeurs étant alors appelés ses fils. La différence entre une arborescence et un arbre
tient seulement au fait que, dans un arbre, les fils d’un sommet sont ordonnés.
On peut montrer qu’un graphe connexe sans cycle est une arborescence. Donc si G est
un graphe sans cycle, G est aussi la forêt constituée par ses composantes connexes.
Une arborescence est représentée par son vecteur père, π[n], où n est le nombre de
sommets de l’arborescence, tel que π[i] est le père du sommet i. Par convention, π[r] = NULL.
Définition 6.3. Dans un graphe G = (S, A), pour chaque sommet x, une arborescence des
plus courts chemins de racine x est une arborescence (Y, B, x) telle que
– un sommet y appartient à Y si, et seulement si, il existe un chemin d’origine x et
d’extrémité y.
– la longueur du plus court chemin de x à y dans G est égale à la profondeur de y dans
l’arborescence (Y, B, x).
Remarque : 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 un plus court chemin
entre org(a1 ) et ext(ai ), pour tout i, 1 ≤ i ≤ p. En revanche, elle n’est pas toujours unique.
Théorème 6.4.1. Pour tout graphe G = (S, A), et tout sommet x de G, il existe une
arborescence des plus courts chemins de racine x.
Preuve. Soit x, un sommet de S. Nous allons construire une arborescence des plus courts
chemins de racine x. On considère la suite {Yi }i d’ensembles de sommets suivante :
– Y0 = {x}.
– Y1 = Succ(x), l’ensemble des successeurs 2 de x.
2. Si (x, x) ∈ A, alors on enlève x de cette liste de successeurs.
& M. Finiasz 93
§ 6.4 Parcours de graphes ENSTA – cours IN101
1 struct vertex {
2 int num;
3 vertices_list* successors;
4 };
5 struct vertices_list {
6 vertex* vert;
7 vertices_list* next;
8 };
9 int n;
10 int* color = NULL;
11 int* dist = NULL;
12 int* father = NULL;
13
14 void init(vertex* root, int nb) {
15 int i;
16 n = nb;
17 if (color == NULL) {
18 /* initialiser uniquement si cela n’a jamais été initialisé */
19 color = (int* ) malloc(n*sizeof(int ));
94 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
Pour simplifier, la gestion des files à été réduite à son minimum : on a en fait deux files,
l’une dans laquelle on ne fait que retirer des éléments avec le fonction pop et l’autre dans
laquelle on ajoute des éléments avec la fonction push. La fonction swap queues permet
d’échanger ces deux files et la fonction queue is empty retourne vrai quand la première
file est vide (on n’a plus de sommets à retirer).
Chaque parcours en largeur à partir d’un sommet fournissant une seule composante
connexe du graphe, si le graphe en possède plusieurs il faudra nécessairement appeler la
fonction minimum spanning tree plusieurs fois avec comme argument à chaque fois un
sommet x non encore visité. Si on se donne un tableau vertices contenant des poin-
teurs vers tous les sommets du graphe, un algorithme de parcours en largeur de toutes les
composantes connexes est alors :
& M. Finiasz 95
§ 6.4 Parcours de graphes ENSTA – cours IN101
0 3
5 1
2
4
1 1
8 3
1
0
7
0
0 1 2 3 4 5 6 7 8
father -1 4 7 0 3 -1 0 0 5
1
2
6
2 dist 0 3 2 1 2 0 1 1 1
Attention
Cette complexité est valable pour une représentation du graphe par listes de succes-
seurs, mais dans le cas d’une représentation par matrice d’adjacence, l’accès à tous les
successeurs d’un sommet nécessite de parcourir une ligne complète de la matrice. La
complexité de l’algorithme devient alors Θ(n2 ).
96 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
1 int n;
2 int date = 0;
3 int* color = NULL;
4 int* father = NULL;
5 int* beg = NULL;
6 int* end = NULL;
7
8 void init(int nb) {
9 int i;
10 n = nb;
11 if (color == NULL) {
12 /* initialise uniquement si cela n’a jamais été initialisé */
13 color = (int* ) malloc(n*sizeof(int ));
14 father = (int* ) malloc(n*sizeof(int ));
15 beg = (int* ) malloc(n*sizeof(int ));
16 end = (int* ) malloc(n*sizeof(int ));
17 for (i=0; i<n; i++) {
18 color[i] = 0; /* 0 = blanc, 1 = gris, 2 = noir */
19 father[i] = -1; /* -1 = pas de père */
20 beg[i] = -1; /* -1 = pas de date */
21 end[i] = -1; /* -1 = pas de date */
22 }
23 }
24 }
25
26 void depth_first_spanning_tree(vertex* x) {
27 vertices_list* tmp;
28 color[x->num] = 1;
3. Une version itérative de l’algorithme peut être obtenue en utilisant une pile.
4. C’est la forêt correspondant aux arcs effectivement utilisés pour explorer les sommets, donc, même
si le graphe est connexe, il se peut qu’un parcours en profondeur de celui-ci produise une forêt (cf. Fi-
gure 6.6). En revanche, si le graphe est fortement connexe, on est certain d’avoir un seul arbre dans cette
forêt.
& M. Finiasz 97
§ 6.4 Parcours de graphes ENSTA – cours IN101
5 1
8 3
7
0 0 1 2 3 4 5 6 7 8
father -1 4 7 0 3 -1 3 0 5
beg 0 3 10 1 2 14 6 9 15
6
2 end 13 4 11 8 5 17 7 12 16
29 beg[x->num] = date;
30 date++;
31 tmp = x->successors;
32 while (tmp != NULL) {
33 if (color[tmp->vert->num] == 0) {
34 /* un successeur non encore visité a été trouvé */
35 father[tmp->vert->num] = x->num;
36 depth_first_spanning_tree(tmp->vert);
37 }
38 tmp = tmp->next;
39 }
40 /* une fois tous les successeurs traités
41 le traitement du sommet x est fini */
42 color[x->num] = 2;
43 end[x->num] = date;
44 date++;
45 }
Comme pour le parcours en largeur, il faut maintenant appeler cet algorithme pour chaque
arbre de la forêt. On utilise donc la fonction suivante :
98 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
9 }
10 }
Le parcours en profondeur passe une seule fois par chaque sommet et fait un nombre
d’opérations proportionnel au nombre d’arcs. La complexité d’un tel parcours est donc en
Θ(n + |A|).
& M. Finiasz 99
§ 6.5 Applications des parcours de graphes ENSTA – cours IN101
100 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
fondeur permet de retrouver toutes les composantes fortement connexes. C’est l’algorithme
de Tarjan, inventé en 1972. Le principe de l’algorithme est le suivant :
– on effectue un parcours en profondeur du graphe,
– à chaque fois que l’on traite un nouveau sommet on lui attribue un index (attribué par
ordre croissant) et un lowlink initialisé à la valeur de l’index et on ajoute le sommet
à une pile,
– le lowlink correspond au plus petit index accessible par un chemin partant du sommet
en question, donc à la fin du traitement d’un sommet on met à jour son lowlink pour
être le minimum entre les lowlink de tous ses successeurs et l’index du sommet courant,
– chaque fois qu’un successeur d’un sommet est déjà colorié en gris on met à jour le
lowlink de ce sommet en conséquence,
– à chaque fois que l’on a fini de traiter un sommet dont le lowlink est égal à l’index on a
trouvé une composante fortement connexe. On peut retrouver l’ensemble des éléments
de cette composante en retirant des éléments de la pile jusqu’à atteindre le sommet
courant.
Cet algorithme est donc un peu plus compliqué que les précédents, mais reste relative-
ment simple à comprendre. Si on a un graphe acyclique, à aucun moment un successeur
d’un sommet n’aura un index plus petit que le sommet courant. Du coup, les lowlink
restent toujours égaux à l’index du sommet et à la fin du traitement de chaque sommet une
nouvelle composant connexe a été trouvée : cette composante contient juste le sommet cou-
rant et on retrouve bien le résultat attendu : un graphe acyclique contient n composantes
fortement connexes composées d’un seul sommet.
Maintenant, si le graphe contient un cycle, tous les éléments de ce cycle vont avoir leur
lowlink égal à l’index du premier sommet visité. Donc, un seul sommet du cycle aura un
son index égal à son lowlink : le premier visité, qui est donc aussi le plus profond dans
la pile. Tous les éléments qui sortiront de la pile avant lui sont alors les autres sommets
de la composante fortement connexe formée par le cycle. Voici le code correspondant à
l’algorithme de Tarjan (un exemple d’exécution est visible sur la Figure 6.7) :
1 int cur_index = 0;
2 int color[n]; /* initialisés à 0 */
3 int index[n]; /* initialisés à -1 */
4 int lowlink[n]; /* initialisés à -1 */
5
6 void Tarjan(vertex* x) {
7 vertices_list* tmp;
8 vertex* tmp2;
9 index[x->num] = cur_index;
10 lowlink[x->num] = cur_index;
11 color[x->num] = 1;
12 cur_index++;
13 push(x);
14 tmp = x->successors;
15 while (tmp != NULL) {
16 if (index[tmp->vert->num] == -1) {
Notons que index joue exactement le même rôle que la variable beg dans le parcours en
profondeur, en revanche le coloriage change un peu : on ne colorie un sommet en noir
que lorsqu’il sort de la pile, pas directement quand on a fini de traiter ses successeurs.
Comme pour le parcours en profondeur, cette algorithme ne va pas forcément atteindre
tous les sommets du graphe, il faut donc l’appeler plusieurs fois, exactement comme avec
l’algorithme all depth first spanning trees.
102 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
3 pile 3 pile
0 1 2 3 4 5 0 1 2 3 4 5
4 4
0 index -1 -1 -1 -1 -1 -1 0 index 0 1 -1 3 -1 2
1 1
lowlink -1 -1 -1 -1 -1 -1 lowlink 0 1 -1 3 -1 1
NULL
2 2
color 0 0 0 0 0 0 NULL color 1 2 0 2 0 2 0
5 5
3 pile 3 pile
0 1 2 3 4 5 0 1 2 3 4 5
4 4
0 index 0 -1 -1 -1 -1 -1 0 index 0 1 -1 3 4 2
1 1
NULL
lowlink 0 -1 -1 -1 -1 -1 lowlink 0 1 -1 3 4 1
2
NULL
2
0
color 1 0 0 0 0 0 0 color 1 2 0 2 1 2 4
5 5
3 pile 3 pile
0 1 2 3 4 5 0 1 2 3 4 5
4 4
0 index 0 1 -1 -1 -1 -1 0 index 0 1 5 3 4 2 NULL
1 1
lowlink 0 1 -1 -1 -1 -1
NULL
lowlink 0 1 0 3 4 1 0
2
0 2
4
color 1 1 0 0 0 0 1 color 1 2 1 2 1 2 2
5 5
3 pile 3 pile
0 1 2 3 4 5 0 1 2 3 4 5
4 4
0 index 0 1 -1 -1 -1 2 NULL 0 index 0 1 5 3 4 2 NULL
1 1
lowlink 0 1 -1 -1 -1 1 0 lowlink 0 1 0 3 0 1 0
2
1 2
4
color 1 1 0 0 0 1 5 color 1 2 1 2 1 2 2
5 5
3 pile 3 pile
0 1 2 3 4 5 0 1 2 3 4 5
4 4
0 index 0 1 -1 -1 -1 2 0 index 0 1 5 3 4 2
1 1
lowlink 0 1 -1 -1 -1 1 lowlink 0 1 0 3 0 1
NULL
2 2
color 1 2 0 0 0 2 0 color 2 2 2 2 2 2 NULL
5 5
3 pile
0 1 2 3 4 5
4
0 index 0 1 -1 3 -1 2
1
NULL
lowlink 0 1 -1 3 -1 1
2
0
color 1 2 0 1 0 2 3
5
104 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
(0) (0)
Preuve. Notons ℓi,j , la valeur de la matrice M passée en argument : ℓi,j = p(i, j) ou ∅.
Considérons la suite (ℓ(k) )k de matrices définie par récurrence par
(k)
Alors nous allons prouver par récurrence que le coefficient ℓi,j est égal au coût minimal d’un
chemin reliant le sommet i au sommet j, en ne passant que par des sommets intermédiaires
de numéro inférieur ou égal à k.
(k)
En effet, cette propriété des ℓi,j est vraie pour k = 0. Soit k ≥ 1, et supposons cette
propriété vraie pour k − 1. Chaque chemin reliant le sommet i au sommet j en ne passant
que par des sommets intermédiaires de numéro inférieur ou égal à k peut soit ne pas passer
(k−1)
par k - auquel cas, par hypothèse de récurrence, son coût minimal est égal à ℓi,j - soit être
décomposé en un chemin de i à k, d’éventuels circuits partant et arrivant en k, et un chemin
de k à j. Par hypothèse de récurrence, les coût minimaux de ces chemins intermédiaires
(k−1) (k−1) (k−1)
sont resp. ℓi,k , (ℓk,k )∗ et ℓk,j . Le coût minimal d’un chemin reliant i à j en ne passant
que par des sommets intermédiaires de numéro inférieur ou égal à k est donc donné par la
formule ci-dessus.
(n)
A la fin de l’algorithme, la matrice (ℓi,j ) a été calculée, qui correspond à la matrice des
coûts minimaux des chemins reliant deux sommets quelconques du graphe, en ne passant
que par des sommets intermédiaires de numéro inférieur ou égal à n, i.e. par n’importe
quel sommet.
– Dans l’implémentation de l’algorithme donnée ici on n’utilise qu’une seule matrice, donc
en calculant les coefficients de la fin de la matrice à l’étape k ceux du début ont déjà
été modifiés depuis l’étape k − 1. La matrice M ne suit donc pas exactement la formule
de récurrence de la preuve, mais le résultat final reste quand même identique.
– Lorsque S(⊔, ⊙, ∅, ϵ) = {vrai,faux}(ou, et, faux, vrai), l’algorithme ci-dessus est exac-
tement l’algorithme de Roy-Warshall de calcul de la fermeture transitive de G (on a ici
x∗ =vrai pour tout x ∈ {vrai,faux}).
– Dans le cas spécifique du calcul de plus courts chemins, i.e. lorsque S(⊔, ⊙, ∅, ϵ) =
{R ∪ ∞}(min, +, ∞, 0), l’algorithme de Aho-Hopcroft-Ullman est plus connu sous le
nom d’algorithme de Floyd-Warshall.
L’algorithme AHU calcule le coût minimal d’un chemin entre deux sommets quelconques
du graphe, sans fournir un chemin qui le réalise. Nous présentons ci-après un algorithme
permettant de trouver effectivement un tel chemin.
Calcul d’un chemin de coût minimal. Soit G = (S, A), un graphe sans circuit
représenté par sa matrice d’adjacence, et soit p, une fonction (poids) définie sur S × S
comme ci-dessus. On suppose ici que cette fonction vérifie p(i, i) = 0.
On suppose également que l’on a calculé les coûts minimaux des chemins entre tous
les couples de sommets, i.e. que l’on dispose de la matrice ℓ = (ℓi,j )i,j . Pour calculer ces
chemins, on définit une matrice de liaison Π = (πi,j )i j , de la manière suivante : πi,j = ∞
si i = j ou s’il n’existe aucun chemin entre i et j. Sinon, πi,j est le prédécesseur de j sur
un chemin de coût minimal issu de i.
La connaissance de Π, permet d’imprimer un chemin de coût minimal entre deux som-
mets i et j de G avec l’algorithme récursif suivant (∞ est remplacé par -1) :
Sous-Graphe de Liaison
On définit le sous-graphe de liaison de G pour i par Gπ,i = (Sπ,i , Aπ,i ), où :
Sπ,i = {j ∈ S, πi,j ̸= ∞} ∪ {i},
Aπ,i = {(πi,j , j), j ∈ Sπ,i \ {i}}.
Dans le cas où le poids est donné par la longueur, on peut montrer que Gπ,i est une
arborescence des plus courts chemins de racine i.
Il est possible de modifier l’algorithme AHU pour calculer les matrices (ℓi,j )i,j et Π
en même temps. Le principe est le même que dans l’algorithme initial, i.e. on calcule des
suites de matrices en considérant des sommets intermédiaires d’un chemin de coût minimal.
(k)
Plus précisément, on définit la séquence Π(0) , Π(1) , . . . , Π(n) , où πi,j est le prédécesseur du
sommet j dans un chemin de coût minimal issu de i et ne passant que par des sommets
intermédiaires de numéro inférieur ou égal à k. On a bien entendu Π(n) = Π, et on peut
(k)
exprimer πi,j récursivement par :
{
(0) ∞ si i = j ou p(i, j) = ∅,
πi,j =
i sinon,
et, pour k ≥ 1, {
(k−1) (k) (k−1)
(k) πi,j si ℓi,j = ℓi,j ,
πi,j = (k−1) (k) (k−1) (k−1)
πk,j si ℓi,j = ℓi,k ⊙ ℓk,j .
(0)
Avec les notations utilisées dans la preuve de la proposition 6.5.1 : on a ici ℓi,j = p(i, j).
Au final, on obtient AHU link, la version modifiée de AHU calculant aussi la matrice Π.
Comme l’algorithme AHU de départ cet algorithme prend en argument des matrices déjà
initialisées : πi,j = −1 si (i, j) ∈
/ A et πi,j = i si (i, j) ∈ A.
106 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 6. Graphes
Recherche de motifs
La recherche de motifs dans un texte est une autre opération utile dans plusieurs do-
maines de l’informatique. Une application directe est la recherche (efficace) de séquences
d’acides aminés dans de très longues séquences d’ADN, mais d’autres applications sont
moins évidentes : par exemple, lors de la compilation d’un programme on doit identifier
certains mots clef (for, if, sizeof...), identifier les noms de variables et de fonctions...
Tout cela revient en fait à une recherche d’une multitude de motifs en parallèle.
7.1 Définitions
Un alphabet est un ensemble fini de symboles. Un mot sur un alphabet Σ est une suite
finie de symboles de Σ. Par exemple, pour l’alphabet latin Σ = {a, b, c, ..., z}, les symboles
sont des lettres et un mot est une suite finie de lettres. On considère également le mot vide
noté ε, qui ne contient aucun symbole. La longueur d’un mot est le nombre de symboles
qui le compose : le mot vide ε est donc de longueur 0. Un mot est dit préfixe d’un autre
si ce mot apparaı̂t au début de l’autre (mot est préfixe de motif ). De même il est suffixe
d’un autre s’il apparaı̂t à la fin (tif est suffixe de motif ).
Les mots étant de longueur finie mais quelconque il semble naturelle de les coder avec
une structure de liste, cependant, par soucis d’efficacité, il seront en général codés avec
des tableaux. Dans ce contexte, le problème de la recherche de motif (pattern-matching en
anglais) consiste simplement à trouver toutes les occurrences d’un mot P dans un texte T .
Soient donc T de longueur n codé par T[0]...T[n-1] et P de longueur m codé par
P[0]...P[m-1]. On considère que les éléments de l’alphabet Σ sont codés par des int. La
recherche de motifs va consister à trouver tous les décalages s ∈ [0, n − m] tels que :
L’énoncé de ce problème est donc très simple, en revanche, les algorithmes efficaces pour
y répondre le sont un peu moins.
109
§ 7.2 L’algorithme de Rabin-Karp ENSTA – cours IN101
Un premier algorithme naı̈f. L’algorithme le plus naı̈f pour la recherche de motif est
déduit directement de l’énoncé : on essaye tous les décalages possibles, et pour chaque
décalage on regarde si le texte correspond au motif. Voici le code d’un tel algorithme :
Cet algorithme a une complexité dans le pire cas en Θ(nm) qui n’est clairement pas opti-
male : l’information trouvée à l’étape s est totalement ignorée à l’étape s + 1.
m−1
∑
∀s ∈ [0, n − m], ts = T [s + i]di .
i=0
Une fois ces entiers définis, la recherche de motif consiste simplement à comparer p à
chacun des ts . Cependant, même si on considère que les opérations sur les entiers se font
toujours en temps constant, cette méthode ne permet pas encore de gagner en complexité
car le calcul de chacun des ts a une complexité en Θ(m) et donc calculer tous les ts coûte
Θ(nm). Afin d’améliorer cela on va calculer les ts de façon récursive : entre ts et ts+1 un
symbole est ajouté et un autre enlevé. On a donc :
⌊ ⌋
ts
ts+1 = + dm−1 T [s + m].
d
110 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 7. Recherche de motifs
Si les calculs sur les entiers se font en temps constant, le calcul de tous les ts a alors
une complexité en Θ(n + m) et le coût total de la recherche de motif est aussi Θ(n + m).
Voici le code de cet algorithme :
En pratique, cette méthode est très efficace quand les entiers t et p peuvent être représentés
par un int, mais dès que m et d grandissent cela n’est plus possible. L’utilisation de grands
entiers fait alors perdre l’avantage gagné car un opération sur les grands entiers aura un
coût en Θ(m log(d)) et la complexité totale de l’algorithme devient alors Θ(nm log(d))
(notons que cette complexité est identique à celle de l’algorithme naı̈f : le facteur log(d)
correspond au coût de la comparaison de deux symboles de Σ qui est négligé dans la
complexité de l’algorithme naı̈f).
Toutefois, une solution existe pour améliorer cela : il suffit de faire tous les calculs
modulo un entier q. Chaque fois que le calcul modulo q tombe juste (quand on obtient
p = ts mod q), cela veut dire qu’il est possible que le bon motif soit présent au décalage s,
chaque fois que le calcul tombe faux on est certain que le motif n’est pas présent avec un
décalage s. Quand le calcul tombe juste on peut alors vérifier que le motif est bien présent
avec l’algorithme naı̈f. On obtient alors l’algorithme suivant :
Si q est bien choisi (typiquement une puissance de 2 plus petite que 232 ), la réduction
modulo q peut se faire en temps constant, et tous les calcul d’entiers se font aussi en temps
constant. La complexité de l’algorithme dépend alors du nombre de ≪ fausse alertes ≫ (les
décalages pour lesquels le calcul modulo q est bon, mais le motif n’est pas présent) et du
nombre de fois que le motif est réellement présent. En pratique, la complexité sera souvent
très proche de l’optimal Θ(n + m).
112 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 7. Recherche de motifs
Fonction de transition
Q § Q 1 1
S0 0 S1 0
S0 1 S0 S0 S1 S0 État initial
S1 0 S0 0
S1 État final
S1 1 S1
avoir défini ce qu’est un automate fini, nous verrons comment obtenir un algorithme de
recherche de motif qui aura toujours (pas uniquement dans les cas favorables) une com-
plexité en Θ(n + m|Σ|), donc très proche de l’optimal quand n est grand par rapport à
m.
a b b a,b
b a a b a
S0 S1 S2 S3 S4 S5
b
a
Si w ∈ L(M ), on dit que M accepte le mot w. Par exemple, l’automate de la Figure 7.1
accepte tous les mots binaires qui contiennent un nombre impaire de 0.
114 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 7. Recherche de motifs
premier symbole de w de telle sorte que w = yv, alors on aura aussi λ(wt) = λ(vt). Or le
motif vt est de longueur m et donc λ(vt) peut être calculé en utilisant Mw . L’automate
Mw termine dans l’état Sλ(vt) quand on lui donne le motif vt en entrée : il suffit alors de
faire pointer le lien indexé par t de Sm au Sλ(vt) obtenu.
Il y a |Σ| − 1 tels liens à trouver et le coût pour trouver son extrémité est un parcours
d’automate qui a une complexité Θ(m). Cependant, les m − 1 premiers caractères sont
communs pour les |Σ| − 1 parcours à effectuer. Il suffit donc de faire un parcours de m − 1
caractères puis de recopier les |Σ| − 1 transitions issues de l’état que l’on a atteint. De plus,
le parcours des m − 1 premiers caractères correspond en fait à l’ajout d’un caractère à la
suite des m − 2 caractères parcourus pendant la construction de Mw . Si l’on a mémorisé
l’état auquel aboutit le parcours de ces m − 2 caractères, il faut donc Θ(|Σ|) opérations
pour construire l’automate Mwx à partir de l’automate Mw . Le coût total de la construction
d’un automate Mw pour un motif de longueur m est donc Θ(m|Σ|), mais cela demande
de programmer comme il faut ! Voici un exemple de code C pour la construction d’un tel
automate : P est le motif recherché, m sa longueur et sigma la taille de l’alphabet Σ ; le
tableau d qui est retourné correspond à la fonction de transition δ avec d[i][j] = δ(Si , j).
116 F. Levy-dit-Vehel
Année 2011-2012 Chapitre 7. Recherche de motifs
[^e] [^s]
a e e s
S0 S1 S2 S3 S4
[^e]
[^s]
.
[^ .
a]
[^etr] e
[^etr] S1' S2' .
t r
t e
r
S0
e r .
t r
[^etr] S1 S2 S3
e
[^etr]