Cours
Cours
Polycopié de cours
Algorithmique et Complexité
Elaboré par :
Dr Mahmoud ZENNAKI
Maître de Conférences
[email protected]
Objectifs :
L’objectif est de permettre aux étudiants d’aborder, d’une part, le calcul de la complexité et la mise au
point d’algorithmes de base en informatique principalement les algorithmes de tri et d’autre part, la
manipulation de structures de données développées.
UEF212 - Algorithmique et Complexité 2ème Année Tronc Commun Ingénieur en Informatique - S3
REFERENCES
• D. Beauquier, J. Berstel, P. Chretienne, et al., "Eléments d’algorithmique", volume 8, Masson, 1992.
• G. Brassard, P. Bratley, "Fundamentals of algorithmics", ISBN : 0-13-335068-1, Prentice Hall, Inc. Upper Saddle
River, NJ, USA, 1996.
• T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, "Introduction à l’algorithmique", ISBN : 2-10-003922-9,
2ème édition, Dunod, 2002.
• S. Kannan, M. Naor, S. Rudich, "Implicit Representation of Graphs", SIAM J. on Discrete Math., volume 5, pages
596-603, 1992.
• A. D. Mishra, D. Garg, "Selection of best sorting algorithm", International Journal of Intelligent Information
Processing, 2(2), 363-368, 2008.
• R. Sedgewick, P. Flajolet, "Introduction à l’analyse des algorithmes", ISBN : 2841809579, International
Thomson Publishing, 1998.
Plan
1. Rappels d’algorithmique
2. Qualités et caractéristiques d’un algorithme
3. Définition de la complexité algorithmique
4. Calcul de la complexité
5. Exemples de calcul de complexité
6. Complexité spatiale
7. Différentes formes de complexité
8. Complexité polynomiale et complexité exponentielle
9. Introduction à la théorie de la complexité
1. Rappels d’algorithmique
Intuitivement un algorithme est n’importe quelle procédure de calcul non ambiguë pouvant prendre
une ou plusieurs valeurs en entrée et produisant une ou plusieurs valeurs en sortie. C’est un outil pour
la résolution d’un problème bien spécifié dont l’énoncé indique la relation entre les entrées et les
sorties. L’algorithme peut aussi être défini plus simplement comme une suite finie d’instructions non
ambiguës pouvant être exécutées de façon automatique. Parmi les exemples classiques d’algorithmes,
on cite :
- Calcul du produit de deux matrices
- Calcul de la solution d’un système d’équations linéaire
- Calcul du plus court chemin (algorithme de Dijkstra)
- Tri d’une liste
- Recherche d’un mot dans un dictionnaire
- Recherche sur le Web
Sortie : Une permutation (ordonnancement) {𝑎1′ , 𝑎2′ , … , 𝑎𝑛′ } telle que 𝑎1′ ≤ 𝑎2′ ≤ ⋯ ≤ 𝑎𝑛′
Par exemple, la séquence {1,18,5,4,33} doit être transformée en {1,4,5,18,33} par l’algorithme de
tri. La séquence d’entrée est appelée instance du problème de tri.
a- Principe de fonctionnement
Exemple : recherche séquentielle d’un élément dans une liste non triée. Il s’agit de comparer
successivement tous les éléments de la liste avec l’élément recherché jusqu’à rencontrer celui-ci
ou bien arriver à la fin de la liste.
i 1;
trouvé faux;
Tant que ((non trouvé) et (i <= n))
Si (T[i] = clé) Alors trouvé vrai;
Sinon i i+1;
Fin boucle
Renvoyer trouvé
Exemple :
Remarques
• Les trois méthodes précédentes de description d’un algorithme sont de plus en plus précises et
donc de moins en moins ambiguës.
• Une bonne manière de programmer un algorithme consiste à enchaîner les trois méthodes c'est-
à-dire : avant de passer à la programmation il faut donner le principe puis l’exprimer en pseudo-
code.
• Il est généralement nécessaire de prouver l’algorithme en question c'est-à-dire s’assurer qu’il est
correct dans tous les cas. Il existe des techniques de preuve d’algorithmes mais dépassent le
niveau de ce cours.
Les critères de convergence varient selon les types d'algorithmes. Par exemple, dans les algorithmes de
tri, la convergence est atteinte lorsque les éléments sont correctement ordonnés. La vitesse de
convergence, c’est-à-dire le nombre d'itérations nécessaires pour atteindre le résultat final, est
également une considération importante. Elle peut être influencée par divers facteurs tels que la taille
des données et la complexité des opérations à effectuer.
La convergence des algorithmes est essentielle pour assurer qu'ils sont efficaces et qu'ils donnent des
résultats précis et constants après un nombre fini d'itérations.
En termes formels, un algorithme 𝒜 est dit convergent s'il existe une solution 𝒮 telle que pour toute
séquence d'itérations {𝑥𝑘 } générée par 𝒜, on a lim 𝑥𝑘 = 𝒮.
𝑘→∞
Amélioration de la Convergence
1. Ajustement des Paramètres : Les paramètres de l'algorithme, tels que les taux d'apprentissage ou les
coefficients de régularisation, peuvent être ajustés pour améliorer la convergence.
2. Techniques de Relaxation : Incluent des méthodes comme la sous-relaxation ou la sur-relaxation, qui
modifient les étapes de mise à jour pour accélérer la convergence.
3. Méthodes Multi-échelles : Utilisent différentes échelles de résolution pour améliorer l'efficacité et la
convergence de l'algorithme.
En résumé, la convergence des algorithmes est une propriété essentielle qui garantit leur fiabilité et leur
précision. Les techniques de test de convergence sont variées et peuvent inclure des analyses formelles
ainsi que des évaluations empiriques pour assurer que les algorithmes fonctionnent correctement dans
des conditions variées.
Structures de données
Les structures de données sont une principale caractéristique des algorithmes et peuvent avoir un
impact direct sur leurs complexités. Les structures de données spécifient la manière de représenter les
données d’un problème qui peut être résolu par ordinateur à l’aide d’un algorithme. Le choix d’une
structure de données doit prendre en considération la taille mémoire nécessaire à son implémentation
ainsi que sa facilité d’accès. Une structure de données peut être choisie indépendamment du langage de
programmation qui est utilisé pour l’écriture du programme manipulant ses données. Ce langage est
supposé offrir, d’une façon ou d’une autre, les mécanismes nécessaires pour définir et manipuler ces
structures de données. Les langages de programmation modernes permettent d’attribuer et manipuler
la mémoire disponible de la machine sous forme de variables isolées les unes des autres, sous forme de
tableaux, de pointeurs indiquant la localisation dans la mémoire de l’objet qui nous intéresse ou à l’aide
de structures prédéfinies plus complexes.
Dans le cas des variables isolées ou des tableaux statiques, la place en mémoire est attribuée par le
compilateur et ne peut faire l’objet de modification pendant l’exécution du programme. Cependant
dans le cas de tableaux dynamiques ou listes chaînées, l’allocation de la mémoire nécessaire est
effectuée pendant l’exécution et par conséquent, elle peut augmenter ou diminuer selon le besoin.
Les structures de données classiques peuvent être classées en trois catégories distinctes :
- Les structures linéaires ou séquentielles : appelées ainsi parce que les données sont
organisées sous forme d’une liste les unes derrières les autres. Ce sont des structures qui
peuvent être représentées par des listes linéaires telles que les tableaux ou les listes
chaînées ayant une seule dimension. Dans cette catégorie, il y a lieu de citer les piles,
pour lesquelles les données peuvent être ajoutées ou supprimées à partir d’une même
extrémité ; et les files où les données peuvent être ajoutées à partir d’une extrémité
tandis qu’elles sont supprimées de l’autre extrémité.
- Les structures arborescentes et en particulier les arbres binaires.
- Les structures relationnelles qui prennent en compte des relations existant ou non entre
les entités qu’elles décrivent.
• Complexité temporelle
Si l’on prend en compte pour l’estimation de la complexité les ressources de la machine telles que la
fréquence d’horloge, le nombre de processeurs, le temps d’accès disque etc., on se rend compte
immédiatement de la complication voire l’impossibilité d’une telle tâche. Pour cela, on se contente
souvent d’estimer la relation entre la taille des données et le temps d’exécution, et ceci
indépendamment de l’architecture utilisée.
On définit la complexité d’un algorithme comme étant la mesure du nombre d’opérations élémentaires
qu’il effectue sur un jeu de données. La complexité est exprimée comme une fonction de la taille du jeu
de données.
L’analyse de la complexité consiste généralement à mesurer asymptotiquement le temps requis pour
l’exécution de l’algorithme sur une machine selon un modèle de calcul. Cette mesure permet juste
d’avoir une idée imprécise mais très utile sur le temps d’exécution de l’algorithme en question. Elle
nous permet, entre autres, d’estimer la taille des instances pouvant être traitées sur une machine
donnée en un temps raisonnable. Cependant, l’analyse de la complexité peut s’avérer très difficile
même pour des algorithmes relativement simples nécessitant parfois des outils mathématiques très
poussés.
D’une façon formelle, on peut aussi définir la complexité d’un algorithme 𝒜 comme étant tout ordre de
grandeur du nombre d’opérations élémentaires effectuées pendant le déroulement de 𝒜. Ces notions
seront définies au fur et à mesure de ce chapitre.
• Complexité spatiale
La complexité spatiale représente, quant à elle, l’utilisation mémoire que va nécessiter l’algorithme.
Celle-ci peut aussi dépendre comme pour la complexité temporelle de la taille de l’instance à traiter par
l’algorithme. La complexité spatiale sera brièvement décrite dans la section 6.
4. Calcul de la complexité
Séquence
𝓐 : 𝓙;
𝓚; 𝑇𝒜 (𝑁) = 𝑇𝒥 (𝑁) + 𝑇𝒦 (𝑁)
Alternative
𝓐 : Si 𝓒 Alors 𝓙 Sinon 𝓚 𝑇𝒜 (𝑁) = 𝑇𝒞 (𝑁) + 𝑚𝑎𝑥{𝑇𝒥 (𝑁) , 𝑇𝒦 (𝑁)} : Pire cas
𝑇𝒜 (𝑁) = 𝑇𝒞 (𝑁) + 𝑚𝑖𝑛{𝑇𝒥 (𝑁) , 𝑇𝒦 (𝑁)} : Meilleur cas
Boucles
𝓐 : nb 𝓑 𝑇𝒜 (𝑁) = 𝑛𝑏 × 𝑇ℬ (𝑁)
Récursivité
Etablir la formule de récurrence et en déduire la complexité (voir section 4.5)
Exemple :
L’instruction « c a + b ; » nécessite les quatre opérations élémentaires suivantes :
1- un accès mémoire pour la lecture de la valeur de a,
2- un accès mémoire pour la lecture de la valeur de b,
3- une addition de a et b,
4- un accès mémoire pour l’écriture de la nouvelle valeur de c.
Remarques :
• Il est recommandé de repérer les opérations fondamentales d’un algorithme. Leur nombre
intervient principalement dans l’étude de la complexité. Voici quelques exemples :
Algorithme Opérations fondamentales
Recherche d’un élément Comparaisons
Tri Comparaisons et déplacements (permutations)
Multiplication de matrices Additions et multiplications
• Faire attention aux boucles et à la récursivité ; la complexité finale d’un algorithme provient
généralement de ces deux types d’instructions.
Pour finaliser le calcul de la complexité du tri par insertion, des notions mathématiques sont nécessaires
notamment les suites numériques et la notion d’ordre de grandeur.
Suites numériques
• Suite arithmétique :
𝑛
𝑛(𝑛 + 1)
∑𝑖 = 1 + 2 + ⋯+ 𝑛 =
2
𝑖=1
• Suite géométrique :
𝑛
𝑥 𝑛+1 − 1
∑ 𝑥𝑖 = 1 + 𝑥 + 𝑥2 + ⋯ + 𝑥𝑛 =
𝑥−1
𝑖=0
• Suite harmonique :
𝑛
1 1 1 1
∑ = 1 + + + ⋯ + = 𝐿𝑛(𝑛)
𝑖 2 3 𝑛
𝑖=1
C 4 + C5 + C6 C 4 − C5 − C6
T(N) = ( ) N 2 + (C1 + C 2 + C 3 + + C 7 ) N − (C 2 + C 3 + C 4 + C 7 )
2 2
Et puisqu’on s’intéresse aux valeurs de N très grandes (comportement asymptotique de la complexité),
nous pouvons noter la complexité en utilisant les ordres de grandeurs (voir rappel ci-après) comme suit :
T(N) = O(N2)
Note :
T(N) représente la complexité dans le pire des cas car on n’a pas tenu compte dans notre calcul de la
valeur de l’expression logique de la condition (A[i] > temp) qui détermine le nombre d’exécutions de la
boucle interne. L’algorithme de tri par insertion peut donc prendre moins de temps pour des tableaux
ayant une structure particulière et par conséquent, la complexité est une variable aléatoire. C’est
pourquoi on parle de complexité en moyenne d’un algorithme qu’on peut calculer comme étant
l’espérance de cette variable aléatoire.
Ordres de grandeurs
- Notation (thêta) : soit g une fonction positive d’une variable entière n. (g(n)) désigne l’ensemble
des fonctions positives de la variable n, pour lesquelles il existe deux constantes c 1, c2 et un entier n0,
satisfaisant la relation :
0 ≤ c1 g(n) ≤ f(n) ≤ c2 g(n) n ≥ n0
Par abus de notation on écrit f(n) = (g(n)) pour exprimer que f (g(n)) (malgré que (g(n)) est un
ensemble et f est un élément de celui-ci).
Exemple :
Soit g(n) = n2 et f(n) = 50n2+10n.
Il s’agit de trouver c1, c2 et n0 tels que : c1n2 ≤ 50n2+10n c1 ≤ 50,
50n2+10n ≤ c2n2 c2 ≥ 50.
La figure suivante montre les graphes de trois fonctions f1, f2 et f3. On peut facilement vérifier que
f2 = (f1(N)) et aussi f3 = (f1(N)).
On peut interpréter la relation f(n)=(g(n)) comme suit : pour n assez grand, la fonction f est bornée à
la fois supérieurement et inférieurement par la fonction g, c'est-à-dire que les fonctions f et g sont
égales, à une constante près.
8
x 10
10
6 f1=N3
f2=N3-100N2
2
f3=N3-1000N2
0
-2
0 100 200 300 400 500 600 700 800 900 1000
N
- Notation O (grand o) : Lorsque la fonction f est bornée uniquement supérieurement par la fonction g
on utilise la notation O. O(g(n)) désigne donc l’ensemble des fonctions positives de la variable n, pour
lesquelles il existe une constante c et un entier n0, satisfaisant la relation : 0 ≤ f(n) ≤ c g(n) n ≥ n0
La relation f(n) = O(g(n)) indique que la fonction f est bornée supérieurement par la fonction g pour des
valeurs suffisamment grandes de l’argument n. Donc f(n) = (g(n)) implique que f(n) = O(g(n)) (par abus
de notation). Formellement, on peut écrire (g(n)) O(g(n)).
Exemple : 100n2 + 10n = O(n2) mais on peut aussi écrire 100n = O(n2) ! Car ceci est équivalent à dire que
100n est asymptotiquement bornée supérieurement par n2.
- Notation Ω (grand oméga): Ω(g(n)) désigne l’ensemble des fonctions positives de la variable n, pour
lesquelles il existe une constante c et un entier n0, satisfaisant la relation : 0 ≤ cg(n) ≤ f(n) n ≥ n0
La relation f(n) = Ω(g(n)) indique que la fonction f est bornée inférieurement par la fonction g pour des
valeurs suffisamment grandes de l’argument n. Par conséquent, f(n) = (g(n)) implique que f(n) =
Ω(g(n)) (par abus de notation). Formellement, on peut écrire : (g(n)) Ω(g(n)). Le théorème suivant
découle immédiatement des définitions données :
Les trois notations précédentes peuvent être récapitulées par le schéma suivant :
Ө
Borne Supérieure et Inférieure
Large
Ω O
Borne Inférieure Borne Supérieure
2. Une grande puissance de n croît plus vite qu’une puissance inférieure (nr = O(ns) si 0≤r≤ s)
3. Le taux de croissance d’une somme de termes est le taux de croissance du terme le plus rapide
en croissance. (Ex : an3 + bn2 = O(n3))
4. Les fonctions exponentielles sont plus rapides en croissance que les puissances (polynômes)
IMPORTANT
La complexité d’un algorithme peut être dans certains cas facilement trouvée si on arrive à analyser et
comprendre l’algorithme et repérer ses opérations fondamentales. Ainsi pour le tri par insertion, on
peut s’intéresser aux comparaisons comme suit :
Le nombre de comparaisons exécutées à l’itération 𝑖 est au plus 𝑖. Le nombre total de comparaisons est :
𝑁
𝑁(𝑁 + 1)
∑𝑖 = − 1 = 𝑂(𝑁 2 )
2
𝑖=2
int fact(int n) {
if (n==0) return 1;
else return n * fact(n-1);
}
Dans ce cas, le paramètre de complexité (taille du problème) est la valeur de 𝑛. Aussi il n’y a qu’un seul
cas d’exécution (pas de meilleur ou pire cas). On remarque donc que :
Il existe plusieurs techniques pour la résolution des équations de récurrence (fonction génératrice,
fractions rationnelles, transformée en Z, …). Le théorème suivant est la solution des principales
équations de récurrence.
Théorème :
Soit 𝑇(𝑛) une fonction définie par l’équation de récurrence suivante, où 𝑏 ≥ 2, 𝑘 ≥ 0, 𝑎 > 0, 𝑐 > 0 :
• Recherche séquentielle : si l’élément est introuvable, tous les N éléments sont parcourus, ce qui
donne une complexité pire cas de O(N).
ideb = 1;
ifin = n;
trouve = false;
while (ideb<=ifin && !trouve) {
imil = (ideb+ifin)/2;
if (A[imil]==e) trouve = true;
else if (A[imil]>e) ifin = imil-1; else ideb = imil+1;
}
Le nombre d’itérations dans le pire des cas est égal à 𝑙𝑜𝑔2 (𝑛) ; en effet l’espace de recherche
évolue comme suit : 𝑛, 𝑛/2, 𝑛/4, …, 𝑛/2𝑝 , jusqu’à ce qu’il ne reste qu’une seule case. Si on pose
𝑛/2𝑝 = 1, on déduit le nombre d’itérations 𝑝 égal à 𝑙𝑜𝑔2 (𝑛).
• Tour de Hanoi
Procedure Hanoï(N, A, C, B)
{ si N0 alors
Hanoï(N-1,A,B,C);
Déplacer le disque de A vers C
Hanoï(N-1,B,C,A);
finsi
}
Il suffit de développer la formule de récurrence suivante : 𝑇(𝑁) = 2𝑇(𝑁 − 1) + 𝑂(1) et 𝑇(0) = 𝑂(1)
6. Complexité spatiale
Jusqu’à présent, nous avons abordé la complexité algorithmique seulement à travers le prisme du temps
de calcul. Pour qu’un calcul soit utilisable, il est en effet nécessaire qu’il s’exécute rapidement. Mais il
existe une autre ressource critique : la mémoire. On s’intéresse donc dans cette section à la complexité
spatiale, autrement dit, l’utilisation mémoire que va nécessiter l’algorithme. Celle-ci peut aussi
dépendre comme pour la complexité temporelle de la taille de l’instance à traiter par l’algorithme.
Prenons comme exemple, un algorithme effectuant 𝑛4 opérations qui peut éventuellement encore être
considéré comme raisonnablement efficace en termes de temps d’exécution puisque le temps
nécessaire à une machine actuelle effectuant 1011 opérations par seconde pour exécuter l’algorithme
sur une entrée de taille 10 000 est d’environ un jour. Mais si à chaque étape il utilise une nouvelle case
mémoire alors il lui faudra 1015 cases pour réaliser son calcul. C’est rédhibitoire actuellement, tout au
moins si l’on se restreint à la mémoire vive afin d’éviter les lents accès disque. Toutefois, la mémoire
n'est plus de nos jours un paramètre réellement limitant, le prix de la RAM ayant énormément baissé en
une vingtaine d'années (de plus la mémoire virtuelle est une autre réponse à ce problème).
Il y a une règle (non officielle) qui dit que pour gagner du temps de calcul, on doit utiliser davantage
d’espace mémoire. Ceci est illustré par l’exemple classique de permutation de deux variables entières 𝑥
et 𝑦 :
La première méthode utilise une variable supplémentaire et réalise 3 affectations (complexité spatiale =
1, complexité en temps = 3) alors que la deuxième méthode n’utilise que les deux variables 𝑥 et 𝑦 à
permuter, mais réalise 3 affectations et 3 opérations (complexité spatiale = 0, complexité en temps = 6).
La mesure de la complexité spatiale se fait généralement en estimant la quantité mémoire utilisée par le
programme comme illustré dans l’exemple très simple d’une fonction permettant de déterminer l’indice
du premier élément négatif dans un tableau de 𝑛 entiers.
La complexité spatiale de la fonction firstNegative est donc O(1), car le tableau T existe déjà en entrée.
En réalité une complexité est dite polynomiale si elle peut être bornée par un polynôme, on retrouve
donc :
𝑂(1) (Complexité constante)
𝑂(𝑙𝑜𝑔(𝑁)) (Complexité logarithmique)
𝑂(√𝑁) (Complexité racinaire)
𝑂(𝑁) (Complexité linéaire)
𝑂(𝑁𝑙𝑜𝑔𝑁) (Complexité quasi-linéaire)
𝑂(𝑁²) (Complexité quadratique)
𝑂(𝑁 3 ) (Complexité cubique)
…
D’autre part une complexité est dite exponentielle (𝑂(𝑘 𝑁 ) en général) si elle ne peut pas être bornée
par un polynôme, on retrouve par exemple :
O(𝑁𝑙𝑜𝑔𝑁 ) (Complexité sous exponentielle)
𝑁 𝑁
𝑂(2 ), 𝑂(3 ) (Complexité exponentielle)
𝑂(𝑁!) (Complexité factorielle)
2𝑁
𝑂(2 ) (Complexité double exponentielle)
Un bon algorithme est polynomial. Ce critère d’efficacité est confirmé par la pratique :
- Une exponentielle dépasse tout polynôme pour 𝑛 assez grand. Par exemple, 1.1𝑛 croit d’abord
lentement, mais finit par dépasser 𝑛100 . En plus il est rare de trouver des complexités
polynomiales d’ordre supérieur à 𝑛5 en pratique.
L’esprit humain a du mal à appréhender ce qu’est une croissance exponentielle. Le tableau suivant
illustre ces croissances, par rapport à des croissances polynomiales. Les temps de calculs supposent 0.1
nanoseconde par instruction (équivalent à un processeur cadencé à plus de 6 GHZ). Les cases non
remplies ont des durées supérieures à 1000 milliards d’années (supérieur à l’âge estimé de l’univers !).
Taille
20 50 100 200 500 1000
Complexité
107 𝑛𝑙𝑜𝑔2 (𝑛) 0.09 s 0.3 s 0.7 s 1.5 s 4.5 s 10 s
106 𝑛² 0.04 s 0.25 s 1s 4s 25 s 100 s
105 𝑛3 0.08 s 1.25 s 10 s 80 s 21 min 2.7 h
𝑛log 𝑛 0.04 ms 0.4 s 32 min 1.2 ans 5. 107 ans --
2𝑛 100 s 31 h -- -- -- --
𝑛! 7.7 ans -- -- -- -- --
Ces calculs montrent que la croissance exponentielle rend rapidement prohibitive l’utilisation d’un
algorithme exponentiel si on doit traiter des données de grande taille. Elle laisse aussi deviner que des
progrès technologiques qui permettraient de rendre plus rapides nos ordinateurs actuels ne
changeraient pas qualitativement la conclusion de la phrase précédente à moins que les ordinateurs
quantiques voient dans le futur le jour !
Classes P et NP
L’ensemble des problèmes qui admettent des algorithmes polynomiaux forme la classe P.
La classe NP (Non-déterministe Polynomial) est celle des problèmes dont une proposition de solution
« Oui » est vérifiable en un temps polynomial. Prenons par exemple une variante du problème du sac à
dos :
Etant donné k, existe-t-il x tel que a.x b et c.x k ?
problème est donc dans NP mais n’est pas dans P1 car aucun algorithme polynomial n’a été trouvé pour
le résoudre.
Problèmes NP-complets
Il s’agit des problèmes les plus difficiles de NP. La notion de problème NP-complet est basée sur celle de
la transformation polynomiale d’un problème. Par exemple, le problème de la recherche d’un stable
maximal d’un graphe G revient à trouver une clique maximale dans le graphe complémentaire GC.
Supposons qu’on ait un algorithme efficace pour trouver une clique maximale et qu’on veuille savoir si G
contient un stable maximal. Ce problème peut être résolu par transformation polynomiale en un
problème de clique maximale dans GC. La transformation consiste à construire GC qui est possible en
O(N²).
Les problèmes NP-complets représentent le noyau dur de NP, car si on trouvait un algorithme
polynomial pour un seul problème NP-complet X, on pourrait en déduire un autre pour tout autre
problème difficile Y de NP. En effet, ce dernier algorithme consisterait à transformer polynomialement
les données pour Y en données pour X, puis à exécuter l’algorithme pour X.
Depuis que le logicien Cook a montré en 1970 que le problème de satisfiabilité (SAT) est NP-complet,
des chercheurs ont montré que la plupart des problèmes difficiles (sans algorithme polynomial) sont en
fait NP-complets.
Exemples de problèmes NP-complets : Satisfiabilité, sac à dos, voyageur de commerce, stable max,
clique max, couverture d’ensembles, …
La conjecture P NP
La question cruciale de la TC est de savoir si les problèmes NP-complets peuvent être résolus
polynomialement, auquel cas P = NP, ou bien si on ne leur trouvera jamais d’algorithmes polynomiaux,
auquel cas P NP. Cette question n’est pas définitivement tranchée. La découverte d’un algorithme
polynomial pour un seul problème NP-complet permettrait de les résoudre facilement tous. Comme des
centaines de problèmes NP-complets résistent en bloc à l’assaut des chercheurs, on conjecture
aujourd’hui que P NP.
Le schéma suivant résume bien cette conjecture. Les problèmes à statut indéterminé sont des
problèmes qui n’ont pas d’algorithme polynomial connu, mais personne n’a réussi à montrer qu’ils sont
NP-complets (problème de l’isomorphisme de 2 graphes par exemple).
NP-Complet
Problèmes à statut
indéterminé NP
1
Tout problème de la classe P est bien évidemment dans NP, puisque l’algorithme de vérification de la solution possède au plus la même
complexité que l’algorithme qui a permis d’obtenir cette solution.
Plan
1. Présentation
2. Tri par sélection
3. Tri par insertion
4. Tri à bulles
5. Tri fusion
6. Tri rapide
7. Etude comparative de la complexité des algorithmes de tri étudiés
1. Présentation
Le tri est sans doute un des problèmes les plus fondamentaux de l’algorithmique. Après le tri beaucoup
d’autres problèmes deviennent facile à résoudre tels que l’unicité ou la recherche.
Dans ce qui suit, on décrit les principaux algorithmes de tri puis on analysera leur complexité
temporelle.
2.1. Principe
Itérativement, le tri par sélection consiste à chercher le plus petit élément puis le mettre au début.
Exemple
Liste initiale 42 17 13 28 14
1ère itération 13 17 42 28 14
2ème itération 13 14 42 28 17
3ème itération 13 14 17 28 42
4ème itération 13 14 17 28 42
2.2. Implémentation
void selectionSort(int *A, int n)
{
for (int i=0 ; i<n-1 ; i++)
{ int imin=i;
for (int j=i+1 ; j<n ; j++)
if (A[j]<A[imin]) imin=j;
swap(A,i,imin);
}
}
2.3. Complexité
Le pire et le meilleur cas sont pareils, puisque pour trouver le plus petit élément, (𝑛 − 1) itérations sont
nécessaires, pour le 2ème plus petit élément, (𝑛 − 2) itérations sont effectuées… jusqu’à l’avant dernier
plus petit élément qui nécessite 1 itération. Le nombre total d’itérations est donc :
𝑛−1
𝑛(𝑛 − 1)
∑𝑖 = = 𝑂(𝑛2 )
2
𝑖=1
3.1. Principe
Itérativement, on insère le prochain élément dans la partie qui a été déjà triée précédemment. La partie
de départ qui est triée est le premier élément.
Exemple
Liste initiale 42 17 13 28 14
1ère itération 17 42 13 28 14
2ème itération 13 17 42 28 14
3ème itération 13 17 28 42 14
4ème itération 13 14 17 28 42
3.2. Implémentation
void insertionSort(int *A, int n)
{
for (int i=1 ; i<n ; i++)
{ int temp=A[i], j=i;
while (j>0 && A[j-1]>temp)
{ A[j] = A[j-1]; j--;}
A[j] = temp;
}
}
3.3. Complexité
Comme nous n’avons pas nécessairement à scanner toute la partie déjà triée, le pire et le meilleur cas
sont différents.
Meilleur cas : si le tableau est déjà trié, chaque élément est toujours inséré à la fin de la partie triée ;
nous n’avons à déplacer aucun élément. Comme nous avons à insérer (𝑛 − 1) éléments,
chacun générant seulement une comparaison, la complexité est 𝑂(𝑛).
Pire cas : si le tableau est inversement trié, chaque élément est inséré au début de la partie triée.
Dans ce cas, tous les éléments de la partie triée doivent être déplacés à chaque itération.
La ième itération génère (𝑖 − 1) comparaisons et échanges de valeurs :
𝑛
𝑛(𝑛 − 1)
∑(𝑖 − 1) = = 𝑂(𝑛2 )
2
𝑖=1
Remarquons pour ce tri que le nombre de permutations dans le pire des cas est de l’ordre de 𝑂(𝑛2 )
4.1. Principe
Parcourir le tableau en comparant deux à deux les éléments successifs et permuter s’ils ne sont pas dans
l’ordre.
Exemple
Liste initiale 42 17 13 28 14
1ère itération 13 42 17 14 28
2ème itération 13 14 42 17 28
3ème itération 13 14 17 42 28
4ème itération 13 14 17 28 42
4.2. Implémentation
void bubbleSort(int *A, int n) {
for (int i=0 ; i<n-1 ; i++)
for (int j=n-1 ; j>i ; j--) if (A[j]<A[j-1]) swap(A,j,j-1);
}
4.3. Complexité
Globalement, c’est la même complexité que le tri par sélection. Le meilleur et le pire cas sont pareils
avec une complexité de 𝑂(𝑛²). Sauf que par rapport au tri par sélection, dans ce tri même le nombre de
permutations est de l’ordre de 𝑂(𝑛²). Néanmoins, si la liste est déjà triée, le tri à bulles peut être
amélioré pour détecter cela après une seule passe grâce à l'utilisation d'un indicateur (flag) qui détecte
si des échanges ont été effectués. Si aucun échange n'a eu lieu, cela signifie que la liste est triée et
l'algorithme peut s'arrêter immédiatement. La complexité meilleur cas devient alors 𝑂(𝑛).
Exemple 42 17 13 28 14
42 17 13 28 14
42 17 13 28 14
28 14
14 28
17 42 13 14 28
13 14 17 28 42
5.2. Implémentation
5.3. Complexité
La complexité peut être exprimée par récurrence :
O(1) si n = 1
T(n) = T(n) = O(nlog 2 n)
2T (n / 2) + O( n ) si n 1
Ou bien, on peut dire que le tableau A sera divisé par 2 jusqu’à obtention de tableaux de taille 1, ainsi :
Taille de A : n, n/2, n/4,…, 𝑛/2𝑝 avec 𝑛/2𝑝 = 1 𝑝 = 𝑙𝑜𝑔2 (𝑛)
Puisqu’à chaque étape, une (ou plusieurs) opération(s) de fusion de l’ordre de 𝑂(𝑛) est exécutée sur les
sous tableaux obtenus, on obtient donc 𝑂(𝑛𝑙𝑜𝑔2 𝑛).
6.1. Principe
L’idée de cet algorithme est de diviser le tableau en deux parties séparées par un élément appelé pivot
de telle manière que les éléments de la partie gauche soient tous inférieurs ou égaux à cet élément et
ceux de la partie droite soient tous supérieurs à ce pivot. Cette étape fondamentale du tri rapide
s’appelle le partitionnement.
Choix du pivot : Le choix idéal serait que ça coupe le tableau exactement en deux parties égales
(voir analyse de complexité), mais cela n’est pas toujours possible. On peut
prendre le premier ou le dernier ou de manière aléatoire,…
Partitionnement :
• On parcourt de gauche à droite jusqu’à rencontrer un élément supérieur au pivot.
Dans ce qui suit (exemple & implémentation) on choisit l’élément se trouvant au milieu du tableau
comme pivot.
Exemple
6.2. Implémentation
6.3. Complexité
• Cas favorable :
La meilleure chose qui puisse arriver, c'est qu'à chaque fois que la fonction Partition() est appelée, elle
divise exactement le tableau (ou le sous-tableau) en 2 parties égales.
À la première passe, les 𝑛 éléments du tableau sont comparés avec la valeur pivot pour les balancer à la
droite ou à la gauche. Il y a donc 𝑛 comparaisons. À la seconde passe il y a 2 fonctions Partition() qui
effectuent leur rôle chacune sur leur moitié de tableau. Chaque fonction doit comparer les 𝑛/2 éléments
du sous-tableau pour effectuer le balancement. Donc de fait, il y a encore 𝑛 comparaisons pour cette
passe. Il en sera de même pour les autres itérations.
Nous pouvons exprimer la complexité sous forme de récurrence :
𝑂(1) 𝑠𝑖 𝑛 = 1
𝑇(𝑛) = { 𝑛 𝑇(𝑛) = 𝑂(𝑛𝑙𝑜𝑔2 𝑛)
2𝑇 (2) + 𝑂(𝑛) 𝑠𝑖 𝑛 > 1
• Cas défavorable :
La pire chose qui puisse arriver, c'est qu'à chaque appel de la fonction Partition(), à cause des
circonstances de la disposition des données, celle-ci place la totalité du sous-tableau à droite ou à
gauche (excluant bien sûr l'élément pivot qui est alors à sa place définitive). Dès lors le tri rapide se
transforme en un tri à bulles.
À la première passe, il y aura donc 𝑛 comparaisons. À la seconde passe il y a déjà une valeur ordonnée
et un sous-tableau de 𝑛 − 1 éléments, il y aura donc 𝑛 − 1 comparaisons effectuées par la fonction
Partition(). À la 3ème passe, il y aura 𝑛 − 2 comparaisons, etc.
Pour effectuer le tri au complet, il aura donc fallu en tout 𝑛 passes. D'où la complexité:
𝑛 + (𝑛 − 1) +. . . +2 + 1 = 𝑛(𝑛 + 1)/2
Soit donc O(𝑛²) = complexité pire cas du Quicksort. Mais il reste que la complexité en moyenne est
O(𝑛log𝑛).
• Tri par sélection : Simple à comprendre et à implémenter, mais inefficace pour de grandes listes.
• Tri par insertion : Efficace pour de petites listes ou des listes partiellement triées.
• Tri à bulles : Généralement considéré comme l'un des algorithmes de tri les moins efficaces pour les
grandes listes.
• Tri par fusion : Très efficace et stable, mais nécessite un espace auxiliaire supplémentaire.
• Tri rapide : Très rapide pour la plupart des cas pratiques, mais peut être inefficace (dans le pire des
cas) si le pivot est mal choisi. Utilisé couramment en pratique avec des optimisations comme le pivot
médian de trois. Sa complexité spatiale peut être améliorée en 𝑂(𝑙𝑜𝑔𝑛) grâce à un partitionnement
en place (in-place).
Tableau récapitulatif
Plan
1. Introduction
2. Définitions
3. Arbres binaires
4. Arbres binaires particuliers
1. Introduction
Pour de grandes quantités de données, la complexité linéaire des opérations sur les listes est
prohibitive. Nous étudions dans ce chapitre, les structures de données hiérarchiques ou arborescentes
pour qui le temps d’exécution de la plupart des opérations est de l’ordre de 𝑂(𝑙𝑜𝑔𝑁). Nous
commençons par rappeler les définitions relatives aux arbres en général et les arbres binaires en
particulier, puis nous montrons comment utiliser les arbres binaires pour développer un algorithme de
recherche avec une complexité de 𝑂(𝑙𝑜𝑔𝑁). Nous terminons avec une autre application des arbres
binaires à savoir les tas ainsi qu’un nouvel algorithme efficace de tri dit tri par tas.
2. Définitions
Un arbre est défini récursivement comme étant une structure composée d’éléments appelés nœuds liés
par une relation « Parent/Fils » ; il contient un nœud particulier appelé racine (le seul qui n’a pas de
nœud parent), ainsi qu’une suite ordonnée (qui peut être vide) d’arbres disjoints 𝐴1 , 𝐴2 , … . , 𝐴𝑚 appelés
sous-arbres. Un nœud peut prendre n’importe quel type de donnée : simple (numérique, caractère,…)
ou composé (structure, classe) etc.
Un exemple typique d’arbre est celui de l’arbre généalogique où les nœuds représentent les personnes
et la relation entre nœuds est la relation classique de « parenté ». La figure suivante illustre la
schématisation d’une partie de l’arbre généalogique mathématique (http://www.genealogy.ams.org). La
relation « parenté » est interprétée comme « étudiant ou doctorant de » :
Joseph Lagrange
- Les ancêtres d’un nœud sont les nœuds qui le relient à la racine y compris celle-ci et le nœud lui-
même.
- Les descendants d’un nœud sont les nœuds qui appartiennent au sous-arbre ayant ce nœud comme
racine.
- La profondeur d’un nœud est la longueur du chemin reliant celui-ci à la racine.
- La hauteur d’un arbre est la profondeur max. de ses nœuds.
- La taille d’un arbre est le nombre total de ses nœuds.
Dans l’exemple précédent, on parcourt les nœuds Lagrange, Fourier, Poisson, Dirichlet, Chasles, Plana.
Fin
Début Joseph Lagrange
Le schéma ci-dessus illustre le parcours en préordre comme étant un examen des nœuds dés la
première rencontre uniquement.
Le parcours en postordre de l’arbre précèdent se fait comme suit : Fourier, Dirichlet, Chasles, Poisson,
Plana, Lagrange. Cette opération est similaire à la précédente sauf que cette fois-ci l’examen des nœuds
se fait à la dernière rencontre.
3. Arbres binaires
Un arbre binaire peut être soit vide, soit constitué du nœud racine qui pointe éventuellement vers deux
sous arbres au maximum. A chaque niveau de l’arbre binaire, les deux sous arbres binaires disjoints sont
appelés sous arbre gauche et sous arbre droit. Un arbre binaire désigne souvent un arbre non vide. Les
procédures de parcours des arbres n-aires restent valables pour les arbres binaires et la terminologie
utilisée est légèrement remaniée pour tenir compte de certaines spécificités des arbres binaires. En
effet, lorsqu’on désigne les fils d’un nœud on parle du fils gauche et fils droit dans le cas où l’un deux ou
bien les deux existent.
a
c b
h f e d
g
Remarque : Un arbre binaire est dit :
▪ homogène lorsque tous les nœuds ont deux ou zéro fils.
▪ dégénéré si tous ses nœuds n’ont qu’un seul fils.
▪ complet si chaque niveau de l’arbre est complètement rempli (i.e. le niveau 𝑖 contient 2𝑖 nœuds)
▪ parfait lorsque tous les niveaux sauf éventuellement le dernier sont remplis, et dans ce cas les
feuilles du dernier niveau sont groupées à gauche.
▪ équilibré si pour chaque nœud la différence entre la hauteur du sous arbre gauche et la hauteur
du sous arbre droit ne dépasse pas 1.
Pour implémenter un arbre binaire, on associe à chaque nœud une structure de donnée contenant la
donnée et deux adresses qui pointent vers les deux nœuds fils. Par convention, une adresse nulle
indique un arbre (ou sous arbre) vide. De cette façon, il suffit donc de mémoriser l’adresse de la racine.
struct Noeud{
int element;
int filsGauche;
int filsDroit;
};
Noeud Arbre[MaxNoeuds];
Le pointeur parent permet de faciliter le parcours dans le sens « feuilles → racine ». Une autre variante
consiste à supprimer ce pointeur pour un gain d’espace mémoire et pour simplifier les procédures de
mise à jour de l’arbre binaire, mais au détriment du parcours de l’arbre. Ainsi on peut représenter plus
simplement l’arbre binaire comme suit :
struct Noeud {
int element;
Noeud *filsGauche, *filsDroit;
};
Note : L’arbre binaire est déclaré et initialisé avec l’instruction : Noeud *racine=NULL;
Ces parcours peuvent être implementés aisément en utilisant des routines récursives comme suit :
Noeud *nouveauNoeud(int x) {
Noeud *nouv = new Noeud;
nouv->element = x;
nouv->filsGauche = nouv->filsDroit = NULL;
return nouv;
}
Pour détruire un arbre et récupérer l’espace alloué ; on exécute la routine recursive suivante :
Le programme suivant met en œuvre les routines décrites précédemment pour construire l’arbre
suivant. L’espace mémoire est récupéré à la fin du programme.
10
6 16
2 8 14 18
int main() {
Noeud *SAG = joindre(nouveauNoeud(2),nouveauNoeud(8),6);
Noeud *SAD = joindre(nouveauNoeud(14),nouveauNoeud(18),16);
Noeud *racine = joindre(SAG,SAD,10);
parcoursInfixe(racine);
effacer(racine);
}
3.5. Représentation d’un arbre quelconque sous forme d’un arbre binaire
Même si les arbres binaires sont les plus utilisés en algorithmique, les arbres quelconques ont aussi des
applications importantes. Les routines vues précédemment peuvent aisément être généralisées pour les
arbres quelconques. Il est néanmoins possible de convertir chaque arbre quelconque en arbre binaire en
suivant les étapes suivantes :
1. Supprimez toutes les arêtes de chaque nœud de l’arbre quelconque, à l'exception de l’arête la
plus à gauche (arêtes ℓ pour left).
2. Dessinez des arêtes d'un nœud au nœud qui est à droite, s’il existe, qui est situé au même niveau
(nœuds frères ; arêtes 𝑟 pour right).
3. Générer l’arbre binaire à partir des arêtes ainsi obtenues.
Exemple :
Soit à convertir l’arbre quelconque suivant en arbre binaire :
10
6 2 18 16
8 14 11 9 5 12
Etapes 1 et 2 : 10
ℓ
6 𝑟 𝑟 18 𝑟 16
2
ℓ ℓ
ℓ
8 14 11 9 5 12
𝑟 𝑟 𝑟
Etape 3 :
10
8 2
14 11 18
16
12
A l’aide de tableaux
Considérons un arbre dont les n nœuds sont désignés par 0,1,…,n-1. La meilleure structure « en termes
d’efficacité » qui supporte l’opération parent (n,A) est un tableau A dont l’élément A[i] pointe sur le
parent du nœud i. Par conséquent on peut implémenter un arbre quelconque par un tableau A défini
comme suit :
Il est clair qu’avec cette représentation l’opération parent est de complexité constante
indépendamment de la taille de l’arbre. Cependant, cette représentation complique d’avantage les
opérations utilisant l’information sur les fils d’un nœud. En outre, on ne peut pas imposer un ordre sur
les fils d’un nœud, en particulier les opérations filsLePlusAGauche, filsLePlusADroite ne peuvent être
définies.
Nœud Indice
J. Lagrange 0
J. Fourier 1
S. Poisson 2 0 1 2 3 4 5
G.Plana 3 -1 0 0 0 2 2
G. Dirichlet 4
M. Chasles 5
struct Bloc {
int element;
Bloc *suiv;
};
struct Arbre {
Bloc *Noeuds[MaxNoeuds];
char Etiquettes[MaxNoeuds];
int racine;
};
Noeuds[] Etiquettes[]
0 1 2 3 NULL 0 J. Lagrange
1 NULL 1 J. Fourier
2 4 5 NULL 2 S. Poisson
3 NULL 3 G. Plana
4 NULL 4 G. Dirichlet
5 NULL racine 5 M. Chasles
0
Exercice : Proposer une implémentation des routines citées dans la section 1 en se basant sur cette
représentation.
Représentation dynamique
Vu que le nombre de fils peut varier d’un nœud à un autre et de surcroit n’est pas connu à l’avance, une
représentation dynamique basée sur un bloc associant la donnée du nœud avec des pointeurs vers tous
ses fils serait irréalisable. La solution est simple : Il suffit de garder pour chaque nœud un lien vers le fils
le plus à gauche et un lien vers le nœud représentant le prochain « frère » comme suit :
Joseph Lagrange
struct Noeud {
int element;
Noeud *premierFils;
Noeud *frere;
};
Cette manière d’organiser les données permet de rechercher les éléments avec une complexité de
l’ordre de O(logN) si l’arbre est équilibré. L’avantage de cette méthode de recherche par rapport à la
recherche dichotomique dans une liste triée réside notamment dans l’opération d’insertion. En effet
pour maintenir une liste triée, l’insertion se fait dans le pire des cas (et aussi en moyenne) en O(N), alors
que dans les arbres binaires de recherche, la complexité en moyenne de l’insertion est de l’ordre de
O(logN) pour peu que l’arbre soit équilibré.
Exemple :
25
15 30
12 20 26 35
18 23
29
De par sa structure, le parcours en ordre (infixe) d’un arbre binaire de recherche produit une liste triée
des données qu’il contient. Les procédures classiques de recherche, insertion, suppression, minimum ou
maximum peuvent être aisément effectuées à l’aide de procédures récursives ou itératives.
• Recherche
• Insertion
Il est facile de constater que la complexité des opérations insertion et recherche dans un arbre binaire
de recherche (de taille n) est égale, dans le pire des cas, à la hauteur de l’arbre soit :
- O(log2(n)) dans le cas d’un arbre équilibré.
- O(n) dans le cas d’un arbre dégénéré.
• Suppression
Plusieurs cas sont à considérer, une fois que le nœud à supprimer a été trouvé à partir de sa clé :
• Suppression d'une feuille : Il suffit de l'enlever de l'arbre vu qu'elle n'a pas de fils.
• Suppression d'un nœud avec un enfant : Il faut l'enlever de l'arbre en le remplaçant par son fils.
• Suppression d'un nœud avec deux enfants : Supposons que le nœud à supprimer soit appelé T (le
nœud de valeur 7 dans le graphique ci-dessous). On échange le nœud T avec son successeur le plus
proche (le nœud le plus à gauche du sous-arbre droit - ci-dessous, le nœud de valeur 9) ou son plus
proche prédécesseur (le nœud le plus à droite du sous-arbre gauche - ci-dessous, le nœud de valeur
6). Cela permet de garder une structure d'arbre binaire de recherche. Puis on applique à nouveau la
procédure de suppression à T, qui est maintenant une feuille ou un nœud avec un seul fils.
Facteur d’équilibrage 𝒇
Le facteur d’équilibrage 𝑓 d’un nœud est la différence entre la hauteur de son sous arbre droit et celle de
son sous arbre gauche. Un nœud dont le facteur d’équilibrage est 1, 0 ou -1 est considéré comme
équilibré, autrement il est considéré comme déséquilibré et requiert un équilibrage. Ce facteur est soit
calculé à partir des hauteurs respectives des sous-arbres, soit stocké dans chaque nœud de l’arbre (voir
figure ci-dessous).
Pour cela, il faut identifier trois nœuds qui forment un triplet (grand parent, parent et enfant) et quatre
sous-arbres attachés à ces nœuds. Soit w le nœud qui vient d’être inséré et qui a provoqué un
déséquilibrage de l’arbre. On parcourt l’arbre vers la racine pour identifier les trois nœuds x, y et z
ancêtres de w. Ces trois nœuds peuvent avoir quatre sous-arbres connectés à eux : T0, T1, T2 et T3
comme dans les exemples de la figure ci-dessous :
Puis le rééquilibrage se fait en considérant les trois nœuds dans l’ordre infixe selon les 4 opérations
suivantes :
Complexité
L’arbre étant équilibré, toutes les opérations de rotation ont donc une complexité de l’ordre de
𝑂(𝑙𝑜𝑔𝑁). La routine d’insertion reste par conséquent de complexité logarithmique.
la plus grande hauteur et x l’enfant de y ayant la plus grande hauteur (x et y ne sont pas ancêtres de w).
La restructuration réduit à 1 la hauteur du sous-arbre initialement enraciné à z et ainsi pourrait
déséquilibrer un autre nœud plus haut dans l’arbre. Ainsi, il faut continuer à vérifier l’équilibre jusqu’à
ce que la racine soit atteinte.
L’exemple suivant montre le choix des nœuds x et y et z et le rééquilibrage fait après suppression de
l’élément 32.
60
29
21
25 5 12 16
Le tas peut aussi être ordonné de manière croissante, on parle alors de min-tas (ou tas croissant).
Implémentation
Pour chaque structure de tas correspond une structure linéaire (tableau, liste) particulière obtenue par
la numérotation des nœuds niveau par niveau et de gauche à droite. La racine occupe le premier
élément de la structure.
Il est facile de vérifier (par récurrence) que les nœuds du niveau 𝑖 occupent les rangs entre [2𝑖 , 2𝑖+1 [. Le
parent d’un nœud qui se trouve en position 𝑗, autre que la racine, occupe la position 𝑗/2 , c'est-à-dire
la partie entière de 𝑗 divisé par 2.
1 2 3 4 5 6 7 8
60 29 21 25 5 12 16 9
struct Tas {
int Donnees[MaxSize]; //La case indice 0 ne sera pas utilisée
int taille;
};
Dans le cas d’un max-tas, le principe du tri tas consiste d’abord à échanger la donnée contenue dans la
racine avec celle contenue dans la dernière feuille de l’arbre qui correspond à la dernière case du
tableau représentant le tas. Par conséquent, après cette opération d’échange la donnée contenue dans
la dernière case du tableau est la plus grande et reste inchangée dans le cas du tri par ordre croissant
des données d’un tas. Pour compléter le tri, l’algorithme doit reconstituer les données dont les indices
se situent entre 1 et n-1 en un sous tas pour pouvoir réutiliser la remarque précédente. Pour respecter
la structure d’un tas, la nouvelle donnée qui vient d’être insérée dans la racine doit être éventuellement
déplacée vers le bas pour être plus grande que les données contenues dans ses fils. Cette opération doit
être répétée autant de fois qu’il est nécessaire jusqu'à atteinte d’une feuille du tas et que la donnée qui
descend est plus petite que celles contenues dans ses deux fils.
• Insertion de 40 40
40 16
• Insertion de 16
16 40
• Insertion de 20 16
40 20
• Insertion de 23
16
16
40 20
23 20
23
40
• Insertion de 28
16
23 20
40 28
16 23 20 40 28
• Suppression de la racine 16
16 28 20
23 20 23 23
20 28
40 28 40 40
• Suppression de 20
40 23
16 20
23 28 40 28
• Suppression de 23 28
16 20 23
40
• Suppression de 28
40
16 20 23 28
void triTas(Tas& A)
{
int i,temp;
for (i=A.taille/2;i>=1;i--) deplaceVersLeBas(A,i,A.taille);
for (i=A.taille;i>=2;i--)
{ temp=A.Donnees[1];
A.Donnees[1]=A.Donnees[i];
A.Donnees[i]=temp;
deplaceVersLeBas(A,1,i-1);
}
}
La complexité du tri tas peut être évaluée comme suit : chaque opération de déplacement prend dans le
pire des cas log2(p) où p étant le nombre de nœuds concernés par cette opération. Par conséquent, la
n
complexité totale est O( log 2 ( p) ) = O(nlog2(n)).
p =1
Plan
1. Introduction aux graphes
2. Définitions
3. Représentation des graphes
4. Parcours des graphes
5. Problème des chemins optimaux
x3 x2
2. Définitions
x1
2.1 Graphes orientés (Directed Graphs)
x4 x5
- Un graphe 𝑮 est un couple (𝑿, 𝑼) où :
▪ 𝑋 est un ensemble {𝑥1 , … , 𝑥𝑛 } de nœuds ou sommets.
▪ 𝑈 = {𝑢1 , 𝑢2 , … , 𝑢𝑚 } est une famille de couples ordonnées de sommets appelées arcs.
- Un graphe est dit valué s’il une application 𝐶 : 𝑈 → 𝑅, associant à chaque arc 𝑢 un réel 𝑐𝑢 .
- Chaque arc 𝑢 = (𝑥, 𝑦) a deux extrémités appelées initiale (𝑥) et terminale (𝑦). 𝑢 est dit incident
intérieurement à 𝑥 et incident extérieurement à 𝑦.
- Dans un arc de la forme (𝑥, 𝑦), 𝑥 est appelé prédécesseur de 𝑦 et 𝑦 est dit successeur de 𝑥.
𝑥 et 𝑦 sont appelés sommets voisins (ou adjacents).
- L’ensemble des successeurs de 𝑥 est noté 𝛤(𝑥) et celui de ses prédécesseurs est noté 𝛤 − (𝑥).
- Le nombre de successeurs de 𝑥 est appelé demi degré extérieur et est noté 𝑑𝐺+ (𝑥) = |𝛤(𝑥)|.
Le demi degré intérieur de 𝑥 est défini comme étant 𝑑𝐺− (𝑥) = |𝛤 − (𝑥)|. Le degré de 𝑥 est défini
comme suit : 𝑑𝐺 (𝑥) = 𝑑𝐺+ (𝑥) + 𝑑𝐺− (𝑥).
- La densité d’un graphe est définie par le rapport 𝒎/𝒏² c'est-à-dire le nombre actuel d’arcs de 𝐺
divisé par le nombre maximum d’arcs que peut avoir 𝐺. La plupart des graphes rencontrés en
pratique ne sont pas très dense (à faible densité). Ils sont appelés creux (en Anglais sparse).
2.3 Parcours
- On appelle chemin 𝜇 de longueur 𝑝 toute suite de 𝑝 arcs (𝑢1 , … , 𝑢𝑝 ) telle que l’extrémité initiale
de 𝑢𝑖 est égale à l’extrémité terminale de 𝑢𝑖−1 ∀ 𝑖 > 1, et l’extrémité terminale de 𝑢𝑖 est égale à
l’extrémité initiale de 𝑢𝑖+1 ∀ 𝑖 < 𝑝.
- Un arc est un chemin de longueur 1 et une boucle est un circuit de longueur 1 aussi.
- Si le graphe est non orienté, on parle de chaine au lieu de chemin, et de cycle au lieu de circuit.
- Un graphe est dit complet si toute paire de sommets est connectée par un arc ou une arête.
Un graphe valué 𝐺 = (𝑋, 𝑈, 𝑊) peut être représenté par une matrice 𝑊 (𝑛 × 𝑛) qui donne à la fois les
coûts des arcs et fait fonction de la matrice d’adjacence. Les matrices d’adjacence permettent de
détecter les boucles, la symétrie ainsi que la connectivité. On peut facilement à l’aide de cette structure
de données obtenir la liste des successeurs d’un sommet ainsi que celle de ses prédécesseurs.
Cependant, ces structures ne sont pas adéquates pour les graphes dont la densité est faible.
Exemple : Le graphe donné en exemple à la section 2.1. est représenté par la matrice d’adjacence M :
𝑥1 𝑥2 𝑥3 𝑥4 𝑥5
𝑥1 0 0 0 0 0
𝑥2 1 0 1 0 0
𝑥3 0 0 1 1 0
𝑥4 0 0 1 0 1
𝑥5 0 0 0 0 0
a. Listes Contigües
Les listes d’adjacence implémentent la structure 𝐺 = (𝑋, 𝛤) où les sommets sont rangés
consécutivement dans des tableaux ou bien dans des listes chaînées. Dans le cas des tableaux, on note
par Succ le tableau dont les éléments sont les listes des successeurs des sommets 0,1, … , 𝑛 − 1 dans
l’ordre, c'est-à-dire 𝛤(0), 𝛤(1), … , 𝛤(𝑛 − 1). Le nombre d’éléments de Succ est donc le nombre d’arcs
du graphe à savoir 𝑚. Pour délimiter ces listes successives des successeurs, on fait usage d’une structure
Tête sous forme de tableau à 𝑛 éléments, qui donne pour chaque sommet l’indice dans le tableau Succ
où commence ses successeurs. En effet, les successeurs d’un sommet 𝑦 sont rangés dans Succ de
Tete[y] à Tete[y+1]-1. Dans le cas où un sommet 𝑦 n’a pas de successeurs, on pose Tete[y]=Tete[y+1].
Les graphes non orientés sont codés comme des graphes orientés symétriques. Si [𝑥, 𝑦] est une arête, 𝑥
apparaît dans la liste des successeurs de 𝑦, et 𝑦 dans celle de 𝑥. Dans le cas d’un graphe valué 𝐺 =
(𝑋, 𝑈, 𝑊), on fait usage d’un tableau de poids W en regard du tableau Succ.
1 2 3 4 5 6
1 1 3 5 7 7 Tete
1 3 3 4 3 5 Succ
1 2 3 4 5 6
b. Listes Chaînées
Le même principe peut être appliqué en utilisant des listes chaînées comme suit :
Tete
1 null
2 1 3 null
3 3 4 null
4 3 5 null
5 null
Là aussi, il y a lieu d’ajouter pour chaque bloc une case supplémentaire dans le cas de graphe valué pour
stocker le poids de l’arc.
Algorithme :
Marquer s ; Enfiler(F,s)
Repeter …………………………………………………………………………………………………………………N
x = Defiler(F)
pour tout successeur y non marqué de x ……………………………d+(i) = M
Enfiler(F,y)
Marquer y
Finpour
Jusqu’à FileVide(F)
Algorithme :
Marquer s ; Empiler(P,s)
Repeter
Tant qu’il y a un successeur y à TetePile(P) et non marqué faire
Marquer y
Empiler(P,y)
Fintq
x = Depiler(P)
Jusqu’à PileVide(P)
c. Exemple
Soit un graphe G orienté, dont les sommets sont (s1, s2, s3, s4, s5, s6) représenté par la matrice
d’adjacence suivante. Donner les ordres de parcours en largeur BFS et en profondeur DFS, à partir du
sommet s1.
0 1 1 0 0 1
1 1 0 1 1 0
1 0 0 1 1 0
𝐴=
0 1 1 1 0 1
0 1 1 0 0 1
[0 0 0 1 1 1]
BFS :
s5
s6 s4 s5
File
s3 s6 s4 s5 File
s1 s2 s3 s6 s4 s5 Vide
Sommets Marqués s1 s2,s3,s6 s4,s5
DFS :
s6
s5 s5 s5
Pile s3 s3 s3 s3 s3
s4 s4 s4 s4 s4 s4 s4
s2 s2 s2 s2 s2 s2 s2 s2 s2 Pile
s1 s1 s1 s1 s1 s1 s1 s1 s1 s1 s1 Vide
Sommets Marqués s1 s2 s4 s3 s5 s6
Dans cette section, on s’intéressera au second problème en calculant pour chaque sommet 𝑥 la valeur
du plus court chemin du sommet de départ vers 𝑥 ; V[x], appelée aussi étiquette ou label.
Il existe une famille d’algorithmes qui calculent V[x] d’une manière définitive pour chaque sommet 𝑥.
Ces algorithmes sont appelés à fixation d’étiquettes et le plus répandu de cette classe est l’algorithme
de Dijkstra. D’autres algorithmes affinent jusqu'à la dernière itération l’étiquette de chaque sommet x.
Cette classe est appelée à correction d’étiquettes. Il y a lieu de distinguer les cas suivants :
- Cas 𝑊 constante. Le problème se réduit à celui de la recherche des chemins contenant le plus
petit nombre d’arcs qui peut être résolu par une exploration en largeur.
- Cas 𝑊 ≥ 0. Le problème peut être résolu par l’algorithme de Dijkstra qui est du type à fixation
d’étiquettes et dont la complexité est 𝑂(𝑛²). Il existe une implémentation en structure de tas
dont la complexité est 𝑂(𝑛 𝑙𝑜𝑔 𝑛) .
Les applications des problèmes des chemins optimaux sont nombreuses et diverses. Dans le domaine
des transports, par exemple, on s’intéresse aux chemins optimaux d’une ville 𝑥 à une autre ville 𝑦. Dans
le routage du trafic réseau, on parle des protocoles OSPF (open shortest path first).
Algorithme de Dijkstra
s : sommet de départ
Initialiser le tableau V à +∞ //Valeur des plus court chemins
Initialiser le tableau P à 0 //Détail des chemins (Path)
V[s]=O
P[s]=s
Répéter
// Chercher sommet non fixé de V minimal
Vmin=+
Pour i=1 à n faire
Si (i non fixé et V[i]<Vmin) alors x=i; Vmin=V[i] fsi
Finpour
Si Vmin<+
Fixer x
// Mise à jour des successeurs
Pour chaque successeur y de x faire
Si V[x]+W[x][y] < V[y]
V[y] = V[x]+W[x][y];
P[y] = x
Fsi
Finpour
Fsi
Jusqu’à Vmin=+
La complexité de l’algorithme de Dijkstra dans le pire des cas est 𝑂(𝑛²). Elle peut être améliorée en
O(nlogn) en utilisant un tas pour représenter le tableau V.
Exemple : Soit le graphe orienté valué représenté par la figure suivante. Dérouler l’algorithme de
Dijkstra en prenant comme sommet de départ 1.
Tableau V (Values)
Sommets
1 2 3 4 5 6
Marqués
0 ∞ ∞ ∞ ∞ ∞ 1
4 1 2 ∞ ∞ 3
4 2 5 7 4
4 5 7 2
5 7 5
6 6
Résultat
0 4 1 2 5 6
Final
Tableau P (Paths)
1 2 3 4 5 6
1 0 0 0 0 0
1 1 1 3 3
5