Poly Caml
Poly Caml
Jules Svartz
Lycée Masséna
Lycée Masséna
Préambule
Ce polycopié est issu du cours d’option informatique dispensé aux élèves du lycée Masséna des classes de première
année MPSI (831 et 832), et de deuxième année MP*.
Le polycopié se divise en deux parties, découpage induit par le programme officiel entre première et deuxième
années. Le dernier chapitre (chapitre 15), présente les modules usuels en Ocaml et est donc transversal.
— Le programme de première année est assez ambitieux vu le temps imparti, car l’option informatique ne débute
qu’au second semestre. Il faut, durant ce laps de temps, acquérir la connaissance du langage Caml(light) et les
bases de l’informatique théorique. Le découpage choisi est le suivant.
— Le chapitre 0 est un chapitre d’introduction au langage OCaml, où la comparaison avec le langage Python
est souvent faite : on s’appuie sur les connaissances des élèves en Python pour progresser rapidement, c’est
pourquoi les aspects les plus fonctionnels du langage (structure de liste chaînée et récursivité) sont traités
ultérieurement.
— Le chapitre 1 présente les structures de données usuelles au programme, qui seront un fil conducteur dans
l’enseignement des deux années : piles, files, files de priorité et dictionnaires. Leurs implémentations concrètes
seront vues tout au long du polycopié, on donne en toute fin de document les modules usuels en Ocaml, et
une implémentation complète d’un module pour les files de priorité.
— Le chapitre 2 traite de l’étude théorique des algorithmes (terminaison, correction et complexité), qui ne
font pas usage de récursivité : on se concentre sur les aspects impératifs de Caml, le chapitre en lui-même
étant très proche de ce que les élèves ont déja vu dans le cours commun d’informatique. Néanmoins, la fin
du chapitre est dédiée à l’implémentation des structures de pile et de file dans un tableau.
— Le chapitre 3 aborde la récursivité, et les listes chaînées.
— Le chapitre 4 donne les outils utiles pour l’analyse des algorithmes récursifs. Il est plus mathématique que
les précédents, car la notion de bon ordre est omniprésente pour traiter les questions de terminaison et de
correction. Les récurrences usuelles dans l’étude de la complexité des algorithmes récursifs, en particulier
« diviser pour régner », sont abordées ici.
— Le chapitre 5 présente des exemples d’algorithmes « diviser pour régner », que l’on sait analyser depuis le
chapitre précédent.
— Le chapitre 6 est une introduction à la programmation dynamique, les méthodes gloutonnes sont également
évoquées.
— Enfin, le chapitre 7 est une introduction aux arbres, implémentés uniquement de manière persistante ici.
Après un descriptif mathématique, on se concentre sur l’étude et l’implémentation des arbres binaires et
arbres binaires entiers, plusieurs implémentations sont proposées.
— Le programme de deuxième année est lui aussi ambitieux, riche et très varié. Ce cours ayant été écrit pour une
classe de MP*, on se cantonne rarement au programme officiel. En effet certaines questions émergent naturelle-
ment 1 , et il aurait été dommage de ne pas les traiter. Néanmoins, on reste raisonnable : ce cours n’est pas une
introduction complète à l’algorithmique et a pour but d’être intégralement traité dans l’année.
— Le chapitre 8 est un prolongement du chapitre 7 : on réalise une structure (impérative) de file de prio-
rité à l’aide d’un tas stocké dans un tableau. On réalise également une structure de dictionnaire à l’aide
d’arbres binaires de recherche. Pour garantir une structure efficace, il faut que les arbres soient un minimum
équilibrés, c’est pourquoi on s’écarte légèrement du programme officiel en présentant également les arbres
AVL.
— Le chapitre 9 est un court chapitre présentant les preuves par induction. Ce n’est pas au programme, mais
ce chapitre a été initialement traité en cours parce qu’il a été réclamé par des élèves.
1. Par exemple, on définit les composantes fortement connexes d’un graphe orienté, mais dans le programme officiel ne figure aucun
algorithme permettant de les calculer !
— Le chapitre 10 présente le cours de logique. Bien qu’assez étoffé, il reste dans les clous du programme officiel,
en se restreignant aux expressions logiques sans quantificateurs. Sont évoquées les différences entre syntaxe
et sémantique, les formes canoniques, et les notions de formules satisfiable ou tautologique. L’écriture d’un
programme testant la satisfiabilité d’une expression logique est en général effectuée en travaux pratiques.
— Le chapitre 11 traite des graphes non pondérés. Ce chapitre aborde notamment les parcours en profondeur et
en largeur, et le calcul des composantes connexes d’un graphe non orienté. On s’éloigne un peu du programme
officiel pour étudier les graphes orientés sans circuit ainsi que le calcul des composantes fortement connexes
via l’algorithme de Kosaraju. Une application de ce dernier algorithme en lien avec le chapitre précédent
est la résolution du problème 2-SAT.
— Le chapitre 12 fait suite au précédent, et traite des graphes pondérés. Le chapitre se concentre, comme
le programme officiel, sur des calculs de plus courts chemins. On s’éloigne un peu du programme officiel
pour présenter d’autres algorithes que ceux de Dijkstra et de Floyd-Warshall, et on aborde également le
problème de l’arbre couvrant minimal, qu’on résout par l’algorithme de Prim, facile à comprendre une fois
l’algorithme de Dijkstra étudié.
— Le chapitre 13 traite des langages et expressions rationnelles. Après quelques considérations sur des langages
particuliers (langage des mots de Dyck, notamment), on se concentre sur les langages rationnels. Dans la
perspective du chapitre suivant, sont abordés les expressions rationnelles linéaires. On termine le chapitre
par la résolution (hors programme) d’équations aux langages, via le lemme d’Arden.
— Le chapitre 14 aborde les automates. On reste très proche du programme officiel en étudiant l’algorithme
de Berry-Sethi pour construire un automate reconnaissant un langage rationnel dénoté par une expression
rationnelle donnée. Néanmoins on se sert du chapitre précédent pour montrer qu’un langage reconnaissable
est rationnel, sens hors programme du théorème de Kleene énonçant l’équivalence. Enfin, on aborde le
lemme de l’étoile, hors programme, mais très pratique pour montrer qu’un langage n’est pas rationnel.
— Enfin, le chapitre 15 présente brièvement les implémentations en Ocaml des structures usuelles (piles, files,
dictionnaires), et donne une implémentation personnalisée d’un module pour les files de priorité, utilisant
essentiellement les idées du chapitre 8.
Licence. Cette œuvre est mise à disposition sous licence Attribution - Partage dans les Mêmes Conditions 2.0 France.
Pour voir une copie de cette licence, visitez [Link] ou écrivez à
Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
3 Récursivité et listes 45
3.1 Exemple introductif : la fonction factorielle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
3.2 Pratique de la récursivité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
3.2.1 Ce qui se passe « en interne » : la pile d’appels . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
3.2.2 Récursivité terminale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
3.2.3 Deux exemples de fonctions récursives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
3.2.4 Récursivité croisée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
3.2.5 Attention aux appels qui se chevauchent ! . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
3.3 Un exemple plus complet : les tours de Hanoï . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
3.4 Structure de liste chaînée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
3.4.1 Définition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
3.4.2 Le type list en Caml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
3.4.3 Exemples de fonctions sur les listes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
3.4.4 Construction de listes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
3.4.5 Une implémentation personnalisée des listes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
3.4.6 Implémentation de structures de pile et de file à l’aide de listes chaînées . . . . . . . . . . . . . 54
3.5 Un exemple fondamental : le tri fusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
10 Logique 111
10.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
10.2 Syntaxe des expressions logiques et représentation arborescente . . . . . . . . . . . . . . . . . . . . . . 111
10.2.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
10.2.2 Représentation arborescente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
10.2.3 Simplification de l’écriture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
10.2.4 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
10.3 Sémantique des expressions logiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
10.3.1 Distribution de vérité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
10.3.2 Évaluation d’une expression logique. Équivalence sémantique . . . . . . . . . . . . . . . . . . . 113
10.3.3 Expressions logiques à 2 variables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
10.3.4 Retour au problème de l’introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
10.4 Tautologies, antilogies et formules satisfiables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
10.4.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
10.4.2 Les tautologies : la base du raisonnement mathématique . . . . . . . . . . . . . . . . . . . . . . 115
10.5 Formes normales . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115
10.5.1 Formes normales conjonctives et disjonctives . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115
10.5.2 Mise sous forme canonique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
10.5.3 Formes normales conjonctives et disjonctives canoniques . . . . . . . . . . . . . . . . . . . . . . 118
10.6 Le problème SAT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 120
14 Automates 167
14.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
14.2 Automates finis déterministes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
14.2.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
14.2.2 Équivalence d’automates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168
14.2.3 Automates locaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 170
14.2.4 Les langages reconnaissables sont rationnels (HP) . . . . . . . . . . . . . . . . . . . . . . . . . . 171
14.3 Automates non déterministes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 172
14.3.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 172
14.3.2 Détérminisation d’un automate non déterministe . . . . . . . . . . . . . . . . . . . . . . . . . . 173
14.3.3 Automate de Glushkov et algorithme de Berry-Sethi . . . . . . . . . . . . . . . . . . . . . . . . 174
14.3.4 Théorème de Kleene . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
14.4 Stabilité des langages rationnels . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
14.4.1 Opérations ensemblistes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
14.4.2 Preuve alternative à rationnel ⇒ reconnaissable . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
14.4.3 Préfixes, suffixes, facteurs, sous-mots, miroir... . . . . . . . . . . . . . . . . . . . . . . . . . . . 178
14.5 Lemme de l’étoile (HP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 179
14.6 Application à la reconnaissance de motifs. Expressions régulières étendues. . . . . . . . . . . . . . . . . 179
14.6.1 Reconnaissance de motifs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 179
14.6.2 Algorithme KMP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 180
14.6.3 Autres problèmes de reconnaissance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 181
Première partie
Chapitre 0
Ce chapitre présente de manière succincte les bases de la programmation en Caml. On commence principalement
par les aspects impératifs en s’appuyant sur ce qui est déja connu en Python. On y introduit donc la déclaration de
variables, de fonctions, les types simples et les types impératifs que sont les tableaux et les chaînes de caractères. On
poursuit par les instructions conditionnelles et boucles. Viennent ensuite des aspects plus fonctionnels : filtrages, types
somme et enregistrement, et exceptions.
Programmation impérative. La programmation impérative est un paradigme de programmation qui décrit les
opérations en séquences d’instructions exécutées par l’ordinateur pour modifier l’état du programme. Un état représente
l’ensemble des variables d’un programme. L’exécution d’un programme consiste, à partir d’un état initial, à exécuter
une séquence finie de commandes d’affectation modifiant l’état courant. Les boucles (for et while) sont à disposition
pour permettre la répétition d’instructions et les structures conditionnelles (if, then, else) pour permettre l’exécution
conditionnelle d’instructions.
La quasi-totalité des processeurs qui équipent les ordinateurs sont de nature impérative : ils sont faits pour exécuter
du code écrit sous forme d’opcodes (pour operation codes), qui sont des instructions élémentaires exécutables par le
processeur. L’ensemble des opcodes disponibles forme le langage machine spécifique au processeur et à son architecture.
L’état du programme à un instant donné est défini par le contenu de la mémoire centrale à cet instant, et le programme
lui-même est écrit en style impératif en langage machine, ou le plus souvent dans une traduction lisible par les humains
du langage machine, dénommée assembleur.
Les langages impératifs suivent cette nature, tout en permettant des opérations plus complexes (il n’y a pas de
boucles en assembleur) : c’est pour cela qu’ils sont les plus répandus. Python est un langage de programmation
impératif.
Exemple : la factorielle. Les deux algorithmes suivants donnent des descriptions impérative et fonctionnelle de la
fonction factorielle.
En fait, Python est un langage impératif permettant l’usage de la récursivité, tandis que Caml est un langage
fonctionnel permettant l’usage de la programmation impérative : le style naturel pour implémenter la factorielle est
plutôt le premier pour Python et le deuxième pour Caml, mais il est possible de faire l’inverse.
# 4+1 ;;
- : int = 5
Le prompt (#) est l’invite de l’interpréteur, la ligne 4+1;; a été écrite au clavier. La ligne - : int = 5 est le résultat
du calcul. Comme on le voit sur cet exemple :
— un calcul en Caml termine par deux points-virgules ;;
— Caml procède (avant même l’évaluation) par une analyse de types : il sait avant même de calculer que le résultat
de l’opération est un entier (int).
Caml est un langage fortement typé : toute valeur possède un type, les opérateurs et fonctions prennent en paramètre
et renvoient des données d’un certain type. Le mélange des genres est interdit : on ne peut, comme en Python, définir
une fonction (sans argument, par exemple) qui renvoie dans certains cas un entier, et dans d’autres un booléen, ou
considérer des listes d’éléments inhomogènes. Caml permet cependant la généricité (polymorphisme) : par exemple la
fonction max peut comparer deux entiers ou deux flottants tout comme les relations de comparaisons comme <= : une
fonction de tri permettra de trier une liste d’entiers comme une liste de flottants 1 .
# () ;;
- : unit = ()
0.3.2 Entier
Le type int correspond aux entiers. Sur une implémentation 64 bits, ceux-ci sont restreints à la plage de valeurs 2
[[−262 , 262 − 1]]. Les opérateurs sur les entiers sont +, *, -, / (division entière) et mod (modulo).
# 2*16 ;;
- : int = 32
# 58 mod 14 ;;
- : int = 2
1. Et si on veut faire plus générique, on passera en paramètre de la fonction de tri une fonction de comparaison.
2. Le lecteur attentif aura noté que cette plage contient 263 entiers. En effet, Caml réserve 1 bit pour savoir que le registre contient bien
un entier, et non une adresse mémoire. On n’en dira pas plus.
0.3.3 Flottants
Le type float correspond aux flottants, essentiellement ce sont les mêmes qu’en Python. Les opérations simples
sont les mêmes que pour les entiers (sauf mod qui n’a pas d’équivalent), mais suivis d’un point qui les différencie de
leurs équivalents sur les entiers. Les fonctions usuelles (cos, sin, exp, atan...) sont définies sur les flottants de même
que l’opérateur d’exponentiation 3 (**).
# 2.0 *. 8.9 ;;
- : float = 17.8
# 2.0 ** 8.9 ;;
- : float = 477.712891666845508
# cos 5.0 ;;
- : float = 0.283662185463226246
Comme on l’a dit plus haut, le mélange des genres est interdit :
# 2 *. 3.0 ;;
Characters 0-1:
2 *. 3.0 ;;
^
Error: This expression has type int but an expression was expected of type float
# 2 ** 5 ;;
Characters 0-1:
2 ** 5 ;;
^
Error: This expression has type int but an expression was expected of type float
Dans le premier cas, l’opérateur * est défini sur les entiers, mais 3.0 est un flottant. Dans le deuxième, c’est l’inverse :
** est défini sur les flottants.
0.3.4 Booléens
Semblables aux booléens de Python, le type bool de Caml n’a que deux constantes : true et false (sans majus-
cule !). Les opérateurs 4 sont && (et logique), || (ou logique, la barre s’obtient avec Alt gr + 6) et not (non logique).
En terme de priorité, not est prioritaire sur && qui l’est sur ||.
# true && false ;;
- : bool = false
# false || true ;;
- : bool = true
# not true ;;
- : bool = false
Comme en Python, les opérateurs && et || sont paresseux : si la partie gauche suffit à déterminer le résultat de
l’opération, la partie droite n’est pas évaluée.
# false && 1/0>0 ;;
- : bool = false
# true || 1/0>0 ;;
- : bool = true
Notez que ceci n’empêche pas Caml d’exiger naturellement la cohérence du type. false && 1 n’a pas de sens et
provoquera une erreur.
Si le nom de la variable n’était pas lié à une valeur avant la déclaration locale, il ne l’est toujours pas après :
# let y= exp(1.0) in (y +. 1.0 /. y) /. 2.0 ;;
- : float = 1.54308063481524371
# y ;;
Characters 0-1:
y ;;
^
Error: Unbound value y
Il ne faut pas confondre déclaration locale et simultanée. Si la variable z n’est pas définie avant l’exécution du code
suivant, on obtient une erreur :
# let z=0 and y=z+2 in y+z ;;
Characters 14-15:
let z=0 and y=z+2 in y+z ;;
^
Error: Unbound value z
0.4.4 Références
Les variables de Caml ne sont donc pas des variables au sens traditionnel des langages de programmation, puisqu’il
est impossible de modifier leur valeur. Il est pourtant souvent nécessaire d’utiliser dans les programmes des variables
modifiables comme en Python. En Caml, on utilise pour cela une référence modifiable vers une valeur, c’est-à-dire une
case mémoire dans laquelle on peut lire et écrire le contenu. Pour créer une référence, on applique le constructeur ref
au contenu initial de la case mémoire. Par exemple :
# let x=ref 0 ;;
val x : int ref = {contents = 0}
La variable x est liée à une valeur (de type int ref), qui est une référence pointant vers 0 à la création. Pour lire le
contenu d’une référence, on utilise l’opérateur de déférencement !, qui signifie « contenu de » :
# !x ;;
- : int = 0
De même qu’avec une déclaration « globale » avec let, la liaison entre la variable et la case mémoire pointée est
définitive jusqu’à ce qu’une nouvelle déclaration écrase cette liaison : plus précisément, la valeur de x est ici l’adresse
de la case mémoire (modifiable) qui contient la valeur, et cette adresse est une constante.
Pour modifier le contenu d’une référence on utilise l’opérateur d’affectation :=. Par exemple, x := !x + 1 incrémente
le contenu de la case mémoire pointée par x, de manière similaire à x+=1 ou x=x+1 en Python.
# x:= !x + 1 ;;
- : unit = ()
# !x ;;
- : int = 1
Les références sont liées à la possibilité de faire de la programmation impérative en Caml : on les utilisera donc souvent
dans des boucles. Le type de la valeur pointée par une référence est fixé à la création : on a vu précédemment que
x était de type int ref. On peut de même créer des bool ref, float ref... et des références vers des types plus
complexes. Une remarque pour finir : les opérations x := !x + 1 et x:= !x - 1 étant d’usage très courant, elles ont
un raccourci :
# !x ;;
- : int = 1
# incr x ;
- : unit = ()
# !x
- : int = 2
# decr x ; !x ;;
- : int = 1
Le type d’un tuple de la forme (t1 , t2 , . . . , tp ) est le produit cartésien des types de ses éléments, par exemple
bool * int * float dans l’exemple ci-dessus. Comme on le voit sur la deuxième ligne, les parenthèses sont facul-
tatives. En pratique, les tuples seront souvent utilisés comme valeur de retour de fonctions (voir la suite). On peut
récupérer dans des variables les composantes d’un tuple :
Notons l’existence des fonctions fst et snd qui permettent de récupérer la première et la deuxième composante d’un
couple (tuple de taille 2), et seulement d’un couple :
0.5.2 Tableaux
Les tableaux (array en anglais) sont très similaires aux listes de Python 5 à deux restrictions près :
— la taille du tableau est fixée à la création, et ne peut pas changer : il n’y a donc pas d’équivalent aux fonctions
append et pop pour des tableaux Caml ;
— le type des éléments du tableau est le même pour tous les éléments.
On aura donc des int array, bool array, (int * bool) array, etc... Pour la syntaxe, les éléments sont séparés
par des point-virgules, et placés entre [| et |].
# [|5; 0; 7|] ;;
- : int array = [|5; 0; 7|]
De même qu’en Python, si n est la taille du tableau, les éléments sont indexés de 0 à n−1. Pour l’accès et la modification
des éléments, si t est un tableau de taille n et i un indice (entre 0 et n − 1), on utilise t.(i) pour récupérer la valeur
stockée à l’indice i, et t.(i) <- x pour la changer en x.
Remarquez, que, naturellement, l’expression t.(0) <- 2 a pour type unit, mais elle a un effet de bord : le modification
de la première case du tableau. Pour parcourir des tableaux, on utilise fréquemment des références et des boucles : on
fait donc de la programmation impérative.
On donne enfin (beaucoup) de fonctions sur les tableaux 6 , qui se trouvent dans le module Array. Toutes ne sont
pas à connaître, mais elles sont parfois bien pratiques. Retenez comment on construit un tableau par la données de ses
éléments, comment on accède à ou modifie une entrée d’un tableau, [Link] et [Link], on peut recoder
assez facilement toutes les autres avec !
commande effet
[|0;1;7;8;9|] construit un tableau par donnée explicite des éléments.
t.(i) le i-eme élément de t (0 ≤ i < n avec n la taille de t)
t.(i) <- x remplacer le i-eme élément de t par x.
[Link] t renvoie la longueur de t.
[Link] n x construit un tableau de longueur n contenant des x (attention les éléments sont
physiquement tous égaux !)
[Link] n f construit un tableau de longueur n contenant les f (i) pour i entre 0 et n − 1
[Link] t renvoie une copie t
[Link] t i k renvoie un tableau de longueur k, égal à la portion de t qui démarre à l’indice i
[Link] t1 t2 concatène deux tableaux (ne pas confondre avec Python !)
[Link] q concatène une liste de tableaux (les listes seront vues ultérieurement.)
Array.make_matrix n m x construit une matrice de taille n, m contenant des x (c’est-à-dire un tableau de
tableaux. Un élément est accessible par t.(i).(j)).
[Link] f t crée un tableau dont les éléments sont les f (x) pour x dans t.
[Link] f t applique f sur chaque x de t (f est de type 'a -> unit).
[Link] f t trie le tableau t en place, avec fonction de comparaison f. f x y renvoie un
entier, nul si les éléments sont égaux, strictement positif si x > y, et strictement
négatif sinon.
# let s="abc" ;;
val s : string = "abc"
# s.[0] ;;
- : char = 'a'
Comme on le voit, l’accès à l’élément d’indice i d’une chaîne s se fait avec s.[i]. Bien que semblables à des char array,
les chaînes de caractères ont leur propre syntaxe et leurs propres fonctions associées, voici un récapitulatif de quelques
fonctions 8 .
6. Liste complète ici : [Link]
7. Ce comportement est récent. Avant, s.[i] <- ’a’ permettait de modifier le caractère numéro i par la lettre ’a’. Aujourd’hui, Ocaml
fait la distinction entre chaînes (immuables) et chaînes d’octets (type Bytes), modifiables. On verra à l’occasion les fonctions permettant
de passer d’un type à l’autre.
8. Liste complète ici : [Link]
commande effet
'a' le caractère a, entre apostrophes (Alt gr+7)
"abc" la chaîne de caractère abc
s.[i] le i-ème caractère de s
[Link] s longueur de la chaîne s
[Link] n c création de la chaîne ccc · · · c de longueur n
[Link] s i k extrait la sous-chaîne de taille k commencant a l’indice i de s.
s1^s2 renvoie la concaténation des deux chaînes s1 et s2 .
Pour terminer, un petit avertissement : la longueur d’une chaîne est directement liée au nombre d’octets utilisés pour
représenter un caractère. Attention avec les caractères accentués (qui ne sont pas ASCII) !
# [Link] "é" ;;
- : int = 2
0.6 Fonctions
En Caml, une fonction est une valeur, qui a donc un type. Commençons par regarder quelques fonctions déja
existantes avant de créer les notres.
Comme on le voit sur ces deux exemples, le type d’une fonction est de la forme type du paramètre → type du
résultat. La fonction int_of_float permet d’obtenir un entier à partir d’un flottant (c’est donc la partie entière 9 ).
La fonction fst permet, comme on l’a déja évoqué, d’obtenir la première composante d’un couple. Comme les types
des composantes de ce couple peuvent être quelconque, Caml utilise des types génériques (on dit aussi polymorphes)
pour donner le type de la fonction : un couple générique est de type 'a * 'b, ici le résultat est du type 'a, car
nécessairement du type de la première composante. La fonction est donc bien de type 'a * 'b -> 'a.
On crée ici la fonction qui à un entier x associe x+1. Caml détecte tout seul que le type est int -> int car l’opérateur
+ n’est valable que sur les entiers : le paramètre de la fonction doit donc être un entier. On peut bien sûr associer cette
fonction à une variable :
# let f = function x -> x+1 ;;
val f : int -> int = <fun>
Pour l’appel, on utilise comme en mathématiques la syntaxe fonction (valeur). Les parenthèses sont en fait facul-
tatives :
# f (5) ;;
- : int = 6
f 5 ;;
- : int = 6
# (function x -> x +. 1.0) 2.5 ;;
- : float = 3.5
9. Sur les flottants positifs seulement, car int_of_float (-4.3) vaut -4.
En fait, il existe une autre syntaxe pour déclarer une fonction à un argument, et lui associer simultanément un nom.
En mathématiques, on écrit souvent quelque chose comme « Posons f (x) = x + 1 ». C’est pareil en Caml, mais les
parenthèses sont aussi facultatives. Cette syntaxe est celle que l’on utilisera le plus souvent.
# let f x = x + 1 ;;
val f : int -> int = <fun>
Il n’y a pas de différence avec la construction précédente de f, mais c’est plus pratique !
# max ;;
- : 'a -> 'a -> 'a = <fun>
# max 20 ;;
- : int -> int = <fun>
# max 20 58 ;;
- : int = 58
# [Link] ;; (* création d'un tableau de taille n initialisé avec l'élément x *)
- : int -> 'a -> 'a array = <fun>
# [Link] 5 0 ;;
- : int array = [|0; 0; 0; 0; 0|]
# [Link] ;; (* concaténation de tableaux *)
- : 'a array -> 'a array -> 'a array = <fun>
# [Link] [|2; 0|] ;;
- : int array -> int array = <fun>
Toutes ces fonctions sont curryfiées. Comme on le voit, pour une fonction curryfiée à deux arguments, f a b est
équivalent à (f a) b : la fonction a priorité dans l’évaluation. Inversement, une fonction de type 'a -> 'b -> 'c
est en fait une fonction curryfiée de type 'a -> ('b -> 'c). Toute la discussion se généralise naturellement à des
fonctions à plus de deux arguments.
# (+) ;;
- : int -> int -> int = <fun>
Un moyen d’obtenir une fonction équivalente est de faire un usage répété de function :
10. Du nom du mathématicien et logicien américain Haskell Curry.
Le mot clé fun permet de construire directement des fonctions curryfiées à plusieurs arguments :
# fun x y -> x+y ;;
- : int -> int -> int = <fun>
# (fun x y -> x+y) 1 6 ;;
- : int = 7
En général on préfère donner un nom à la fonction, et on peut utiliser let de la même manière qu’avec un seul
argument :
# let somme x y = x + y ;;
val somme : int -> int -> int = <fun>
# somme 1 6 ;;
- : int = 7
Les constructeurs fun et function sont assez piégeux, en général on les évitera.
On déclarera une fonction curryfiée à n arguments avec la syntaxe let f x1 x2 ... xn = expression
Cette fonction teste si le couple de flottants passé en paramètre est dans le disque unité fermé. Une définition équivalente
déconstruit le couple à l’intérieur du corps de la fonction :
# let f z = let x,y=z in x**2. +. y**2. <= 1. ;;
val f : float * float -> bool = <fun>
Si a et b sont deux expressions de types différentes, l’interpréteur avertit immédiatement d’une erreur.
Ici, l’interpréteur a détecté que la première expression (1.0) avait type float, la deuxième doit avoir ce type également.
L’absence de else est compris comme else (), où () est de type unit. L’expression a doit alors être de type unit :
Cette possibilité sera par exemple utilisée lorsqu’on programmera de manière impérative : une expression modifiant la
valeur pointée par une référence a pour type unit :
# let x = ref 0 ;;
val x : int ref = {contents = 0}
# if true then x:= !x + 1 ;;
- : unit = ()
# let x=ref 0 in x:= !x + 1 ; print_int 8 ; print_string " encore une expression " ; !x ;;
8 encore une expression - : int = 1
Lorsqu’on utilise la construction if, then, else, il ne doit y avoir qu’une seule expression dans les cas then et else.
On peut délimiter une séquence d’instructions par begin, end si besoin. Par exemple :
(Dans le premier cas, print_int est exécuté car en dehors du if ... then. Dans le deuxième on a encadré avec begin
et end). Une petite remarque : on peut éviter l’usage de begin et end par parenthésage, c’est un peu plus court :
0.7.3 Boucles
Boucles for.
La syntaxe d’une boucle for est la suivante : for i = i1 to i2 do instructions done. Le compteur de boucle
(i ici) prend toutes les valeurs entre i1 et i2, par pas de 1. Si i2 < i1, aucune instruction n’est exécutée. Les
instructions, séparées par des point-virgules, ont toutes type unit, qui est aussi le type de la boucle totale. Voici une
fonction, écrite dans un style impératif, qui calcule la factorielle d’un entier.
let fact n=
let y=ref 1 in
for i=1 to n do
y:= !y * i
done ;
!y
;;
Regardons l’exécution :
#fact 5 ;;
- : int = 120
Il n’est pas possible de modifier le compteur de boucle, et c’est très bien comme ça. Petite variante, la syntaxe
for i = i1 downto i2 do instructions done permet d’avoir un pas de −1 (il faut donc que i1 ≥ i2 pour qu’au
moins une intruction soit exécutée).
Boucles while.
La syntaxe est très similaire : while condition do instructions done, où condition est une expression boo-
léenne. L’algorithme d’Euclide suivant, de type int -> int -> int, est écrit avec une boucle while.
let pgcd a b=
let x=ref a and y=ref b in
while !y > 0 do
let r= !x mod !y in
x:= !y ;
y:= r
done ;
!x ;;
Notez l’utilisation d’une variable r locale au corps de boucle, tandis que les références sont locales à la fonction.
Regardons l’exécution :
# pgcd 451 123 ;;
- : int = 41
0.8 Filtrages
0.8.1 Introduction
Le filtrage peut être vu comme une alternative aux if, else, qui peuvent s’enchaîner de manière disgracieuse, mais
est en réalité beaucoup plus que ça ! D’une part, il peut être utilisé pour construire une fonction « par morceaux ». La
fonction suivante
sgn : Z −→ Z
0 si x = 0.
x 7−→ 1 si x > 0.
−1 si x < 0.
Ceci s’étend naturellement à des fonctions de plusieurs arguments, avec l’utilisation de fun. Néanmoins, l’utilisation
de fun et de function étant assez trompeuse, on préfère filtrer avec un match ... with :
let sgn x=match x with
| 0 -> 0
| y -> abs(y)/y
;;
Plus généralement, un filtrage permet de gérer différents motifs possible d’une expression. Mais d’autres part, il permet
d’accéder aux composantes d’un type construit (en particulier récursif), ce qui sera très utile lorsqu’on aura vu les
listes et les arbres, par exemple.
Dans une instruction de la forme if... else..., les expressions dans chaque bloc if et else doivent avoir le même
type. Il en va de même des expressions qui sont résultats d’un filtrage :
Un filtrage se doit d’être exhaustif : c’est-à-dire qu’il doit pouvoir filtrer toutes les valeurs possibles de l’expression en
fonction de son type. Par exemple, dans la fonction suivante, qui simule le lancé d’un dé à 6 faces 11 et indique une
action, le filtrage n’est pas exhaustif (du point de vue de l’interpréteur Caml, qui ne voit que les types) :
Avoir des filtrages non exhaustifs est disgracieux et doit être évité. Le motif « joker » _ (underscore) permet de filtrer
tous les motifs possibles (ou une partie d’un motif). Ainsi, la dernière ligne du filtrage ci-dessus peut avantageusement
être remplacée par
| _ -> "vrille"
De même que l’interpréteur indique si un filtrage est non exhaustif, il indique aussi si un cas de filtrage est inutile :
Le filtrage d’une expression s’effectue par motifs et non par valeurs : la « forme » de l’expression située à gauche qui
est comparée à la valeur filtrée. Tout cela sera plus clair lorsqu’on aura vu les types construits et les listes, mais un
motif est essentiellement :
— une constante (true, 0, (0, "a"), etc...) ;
— un identificateur ;
— le joker _ ;
— une construction de motifs à l’aide de motifs plus simples.
On peut déja faire un exemple avec les couples. Le filtrage suivant réalise des actions différentes suivant que le couple
filtré possède une composante nulle ou non :
match c with
| (0,_) -> ...
| (_,0) -> ...
| _ ->
Lorsqu’un ou plusieurs identificateurs se trouvent dans le motif et que le filtrage réussit, une liaison locale est effectuée
entre les identificateurs et les valeurs filtrées. Par exemple :
11. [Link] n fournit un entier aléatoire de [[0, n − 1]]. Le module Random fournit aussi [Link] f pour un flottant.
match c with
| (0,y) -> y
| (x,0) -> x
| (x,y) -> -x-y
Notons que ce filtrage est bien exhaustif : (x,y) filtre tous les couples possibles. Dans un motif, ne peuvent figurer
que des identificateurs distincts : par exemple filtrer (x,x) n’a aucun sens ! Le mot clé when permet de relâcher un
peu la rigidité du filtrage sur motif : une fois la liaison effectuée, on peut réaliser une comparaison de valeurs. Voici
une réécriture de la fonction 12 xor :
let xor a b=match (a,b) with
| (x,y) when x=y -> false
| _ -> true
;;
Voici une écriture d’une fonction arg donnant un argument d’un nombre complexe identifié à un couple de flottants,
avec un filtrage (on rappelle que π = 4 arctan(1)) :
On a déclaré un type personne, dont les champs sont nom, prenom et age, associés à un type particulier. Attention, le
nom d’un type et les noms des champs doivent être en minuscules. Pour créer un élément de type personne, il suffit
de fixer les valeurs des champs. On accède au champ c d’un élément a d’un type produit avec a.c.
Les motifs de filtrage peuvent être des types produits 13 , par exemple pour tester si une personne est majeure :
En fait, il n’est pas nécessaire de préciser tous les enregistrements dans un filtrage. La fonction suivante est équivalente.
12. Celle ci n’a toutefois pas le même type, elle est polymorphe : ’a -> ’a -> bool.
13. En pratique, on filtrera rarement des types produit.
Champ modifiable. A priori, l’âge d’une personne peut changer. On aurait pu rendre le champ Age mutable, il
aurait fallu procéder ainsi :
Comme on le voit, la modification du champ c de l’élément a en la valeur x se fait avec a.c <- x.
Types paramétrés. On peut faire usage de polymorphisme dans les types produits. Par exemple, définissons nous
même un type similaire aux couples, mais en imposant des éléments homogènes :
# type ('a, 'b, 'c) truc = {un: 'a ; deux: 'b ; trois: ('a * 'c) array ; quatre: bool} ;;
type ('a, 'b, 'c) truc = {un : 'a; deux : 'b; trois : ('a * 'c) array; quatre : bool;}
Recréer manuellement les références. Il est intéressant de voir que l’on peut recréer « manuellement » un type
semblable au type ref de Caml à l’aide d’un enregistrement, contenant un seul champ (naturellement mutable). Pour
pouvoir créer des références vers des types quelconques, on fait naturellement usage de polymorphisme en créant un
type paramétré.
On remarque que ceux-ci sont très similaires aux opérations semblables avec les références de Caml (parenthéser un
opérateur infixe permet de le transformer en opérateur préfixe, c’est-à-dire en une fonction) :
# ref ;;
- : 'a -> 'a ref = <fun>
# ( ! ) ;;
- : 'a ref -> 'a = <fun>
# ( := ) ;;
- : 'a ref -> 'a -> unit = <fun>
Un tel type constitué uniquement de constructeurs constants est dit énuméré. Voici un type mélangeant entiers et
flottants : on utilise deux constructeurs au nom explicite :
#type carte_tarot = Excuse | Roi | Dame | Cavalier | Valet | Atout of int | Petite_carte of int ;;
type carte_tarot = Excuse | Roi | Dame | Cavalier | Valet | Atout of int | Petite_carte of int
#Atout 21 ;;
- : carte_tarot = Atout 21
Fonctions sur les types sommes. On fonctionne énormément par filtrage dans l’utilisation des types somme.
Voici une fonction de négation sur nos booléens fraichement redéfinis :
Paramétrage. Comme pour les types produit, on peut paramétrer en faisant usage de polymorphisme. Voici com-
ment faire quelque chose qui ressemble à A ∪ B avec A et B disjoints.
On peut bien sûr l’utiliser pour manipuler deux copies d’un même ensemble : quelque chose comme Z ∪ Z0 , où Z0 est
une copie de Z, serait représenté par un (int * int) union.
14. On remarque que Roi et Excuse ont été filtrés ensemble. Il est possible de procéder ainsi lorsque dans les motifs de fitrage les
identificateurs sont les mêmes (et ont les mêmes types).
0.10 Exceptions
Un moyen (pas explicitement au programme) pour programmer efficacement en évitant le lourd usage d’une réfé-
rence vers un booléen est d’utiliser des exceptions. Voici quelques exemples produisant des exceptions.
# 1/0 ;;
Exception: Division_by_zero.
# [|5; 2|].(2) ;;
Exception: Invalid_argument "index out of bounds".
# failwith "echec" ;;
Exception: Failure "echec".
Il y a ici 3 exceptions différentes dans cet exemple : Division_by_zero, Invalid_argument et Failure. Les deux
dernières prennent en paramètre une chaîne de caractères.
Les exceptions en Caml forment un type à part entière, qui est le type exn (abbréviation de exception, sans suprise) :
# Division_by_zero ;;
- : exn = Division_by_zero
# Failure "truc" ;;
- : exn = Failure "truc"
À priori, une exception dans un programme Caml est levée lorsqu’on tente de faire une opération interdite : diviser
par zéro, accéder à un indice d’un tableau qui n’est pas défini (comme ci-dessus), etc... La levée d’une exception
interrompt le déroulement du programme, et l’exception remonte de fonctions appelées en fonctions appelantes jusqu’au
programme principal, sauf si elle est rattrapée. Pour rattraper une exception, il suffit d’encadrer un code pouvant
produire une exception par try ... with, et de rattraper l’exception dans le with. Par exemple, la fonction suivante
let sgn x=
try
x/abs(x)
with Division_by_zero -> 0
;;
sgn : Z −→ Z
0 si x = 0.
x 7−→ 1 si x > 0.
−1 si x < 0.
Le mécanisme de rattrapage des exceptions est celui d’un filtrage 15 . On a vu que certaines exceptions pouvaient
prendre en paramètre une valeur d’un certain type, c’est le cas de Failure (exception produite par failwith), qui
est un constructeur à un argument (de type string).
Reprenons la même fonction que précédemment, avec failwith :
let sgn x=
try
if x=0 then failwith "division par zero !" ;
x/abs(x)
with Failure s -> 0 (* filtrage: s est liée localement à la valeur associée à Failure *)
;;
Il est possible de lever nous même des exceptions (autres que Failure avec failwith), par l’utilisation de raise :
#raise ;;
- : exn -> 'a = <fun>
On peut donc, dans le code précédent, remplacer le failwith ... par raise (Failure ... ) : c’est strictement
équivalent. Refaisons le avec Division_by_zero qu’on lève « manuellement » :
let sgn x=
try
if x=0 then raise Division_by_zero ;
x/abs(x)
with Division_by_zero -> 0
;;
Enfin, il est possible de créer nous même des exceptions. Voici un code d’une fonction permettant de chercher si un
élément se trouve dans un tableau, sans référence. On crée une exception « Trouve » qu’on pourra lever pour indiquer
qu’on a trouvé l’élément.
exception Trouve ;;
let appartient t x=
try
for i=0 to [Link] t - 1 do
if t.(i) = x then raise Trouve
done ;
false
with Trouve -> true
;;
Bien sûr, on aurait pu lever n’importe quelle exception (comme Division_by_zero), mais c’est plus parlant ainsi. On
peut également créer une exception prenant un paramètre d’un certain type. La fonction suivante renvoie l’indice d’un
élément dans un tableau, et −1 si l’élément n’y est pas :
let indice t x=
try
for i=0 to [Link] t - 1 do
if t.(i) = x then raise (Indice i)
done ;
-1
with Indice a -> a
;;
Chapitre 1
1.1 Introduction
Un fil rouge du programme des deux années d’option informatique est l’utilisation de structures de données abs-
traites classiques dans des algorithmes. Plus basiquement, pour résoudre un problème donné, un informaticien se
tournera naturellement vers une structure de données bien connue s’il peut en faire usage efficacement. Donnons deux
exemples :
— lors de l’exploration d’un graphe, il faut stocker les sommets découverts mais dont les voisins n’ont pas encore
été examinés. Le choix de la structure (pile ou file) mène à une exploration du graphe dite « en profondeur »
ou « en largeur ». Pour un graphe dont les arêtes sont munies de poids positifs, la distance entre deux sommets
est la somme des poids des arêtes sur un chemin entre les deux sommets (on prend le chemin minimisant cette
somme). Une généralisation du parcours en largeur explorant les sommets depuis une origine fixée, par distance
à l’origine croissante, se fait via l’algorithme de Dijkstra : une file de priorité est utilisée pour gérer les sommets
en cours d’exploration.
— l’informaticien d’une entreprise souhaite référencer les clients, et pouvoir accéder rapidement aux informations
concernant un client à l’aide de son identifiant unique, attribué à la création de la fiche client. Une structure de
dictionnaire est toute indiquée pour stocker les informations sur les clients.
Définition 1.1. Une structure de données abstraite est la donnée d’un type, et des opérations que l’on peut effectuer
dessus.
Cette définition est un peu évasive, mais ce qu’il faut retenir est qu’une structure abstraite est indépendante d’une
implémentation concrète : ce qui est important est la description des opérations que le type permet, plutôt que la
manière dont ces opérations sont réalisées. À cela il y a plusieurs avantages :
— si on possède déja une implémentation d’une structure de données abstraites, on peut programmer des algorithmes
en faisant usage sans savoir comment ces structures sont implémentées : on les voit comme des « boites noires »
complexes. C’est le point de vue du programmeur utilisateur : il n’a pas besoin de savoir comment ça marche
pour utiliser une structure abstraite.
— si on change l’implémentation d’une structure pour une autre implémentation, les algorithmes faisant usage de
la structure que l’on a déja implémentés seront compatibles avec la nouvelle implémentation.
Ce qui permet de juger si une implémentation est meilleure qu’une autre est essentiellement la complexité : lorsqu’on
voudra implémenter concrètement une structure abstraite (on parle de structure concrète), on cherchera à avoir la
meilleure complexité (en temps et/ou en mémoire) possible.
Passons maintenant à la description des quatre structures de données abstraites au programme.
1.2 Piles
Une pile est une structure de donnée linéaire, dans laquelle les éléments sont insérés ou supprimés suivant le principe
LIFO (last in, first out) : visuellement, le seul élément accessible est celui situé au sommet de la pile, qu’il faudrait
retirer pour accéder à l’élément situé en dessous. Inversement, un élément qu’on rajoute à la pile le sera au sommet.
Définition 1.2. Une pile est une structure abstraite, supportant les opérations suivantes :
— création d’une pile vide ;
1.3 Files
La file est une autre structure linéaire, assez semblable à la pile, mais qui fonctionne sur le principe FIFO (first
in, first out) : lorsqu’on insère un élément dans une file, celui-ci ne pourra être retiré qu’après que tous les éléments
insérés avant l’aient été.
Définition 1.3. Une file est une structure abstraite, supportant les opérations suivantes :
— création d’une file vide ;
— test d’égalité au vide ;
— retrait de l’élément en tête d’une file non vide ;
— ajout d’un élément en queue de file.
La file n’est pas d’un usage aussi courant que la pile en informatique, citons néanmoins quelques exemples :
— une imprimante « bas de gamme » (qui ne gère pas les priorités, voir le paragraphe suivant) à qui l’on envoie
des documents à imprimer les traitera séquentiellement : le premier document à être envoyé sera imprimé en
premier ;
— le parcours en largeur d’un graphe traite les sommets par distance à l’origine (nombre minimal d’arêtes à
parcourir depuis l’origine) croissante : il suffit de rajouter les sommets dans une file lorsqu’ils sont découverts
pour respecter cet ordre dans le parcours.
Il existe une structure déja implémentée en Ocaml : le module Queue.
1.5 Dictionnaires
Le dictionnaire est également une structure très courante en informatique. Il stocke des couples (k, e) où k est la
clé, et e l’élément associé. Dans un dictionnaire, les clés k sont supposées toutes distinctes.
Définition 1.5. Un dictionnaire est une structure abstraite, supportant les opérations suivantes :
— création d’un dictionnaire vide ;
— test d’égalité au vide ;
— test de présence d’un élément ayant une clé k donnée ;
— retrait de l’élément ayant une clé k donnée (s’il existe) ;
— ajout d’un élément e avec une clé k donnée (s’il n’y a pas déja d’élément de clé k).
Quelques exemples d’utilisation :
— un dictionnaire (au sens usuel) peut être vu comme un dictionnaire informatique : les clés sont les mots de la
langue, les éléments sont les définitions ;
— un logiciel de programmation fait usage d’un dictionnaire pour stocker les variables définies par l’utilisateur, et
leurs valeurs ;
— dans la réalistion concrète d’une file de priorité, on peut faire usage d’un dictionnaire pour implémenter l’opé-
ration « augmenter ou diminuer la priorité d’un élément ». Les clés de ce dictionnaire sont les éléments x de la
file de priorité ;
— plus généralement, tout stockage de données où on attribut un identifiant unique (la clé) aux éléments stockés
peut être implémenté via un dictionnaire.
Il existe une structure déja implémentée en Ocaml, qui fait usage de tables de hachage (module Hashtbl).
Chapitre 2
En informatique, la programmation impérative est un paradigme de programmation qui décrit les opérations en
séquences d’instructions exécutées par l’ordinateur pour modifier l’état du programme. Bien que ce ne soit pas la
manière de coder la plus utilisée en Caml, qui dérive du langage fonctionnel ML, c’est tout à fait possible. Un objet
typique de la programmation impérative est le tableau, est une fonction de tri qui ne retourne rien (le type unit
en Caml), mais modifie le tableau. Dans ce chapitre, on va donc voir (ou revoir) les boucles, et la manipulation de
tableaux. On en profite pour revoir la notion de terminaison des fonctions itératives, les preuves de correction et l’étude
de leur complexité. Une application de tout ceci se trouve naturellement dans les tris : on se limite ici aux tris de base
qui ne sont pas récursifs.
2.1.1 Terminaison
Pour montrer qu’un algorithme termine quel que soit le jeu de paramètres passé en entrée respectant la spécifi-
cation, il faut montrer que chaque bloc élémentaire décrit ci-dessus termine ! Or, les boucles for et les instructions
conditionnelles terminent forcément. Le seul souci pourrait venir d’une boucle while.
En général, pour montrer la terminaison d’une boucle while on procède ainsi : on exhibe une quantité, dépendant
des paramètres, à valeurs dans N, qui décroît strictement à chaque passage dans la boucle. Puisqu’il n’existe pas de
suite infinie strictement décroissante dans N, cela prouve que la boucle se termine. Cette quantité est appelée un
variant de boucle.
2.1.2 Correction
Pour montrer qu’un algorithme est correct, il s’agit de montrer que quels que soient les paramètres vérifiant sa
spécification, l’action de l’algorithme correspond à ce qui est attendu. Reprenons notre découpage en blocs. Pour
montrer la correction de l’algorithme, il s’agit de montrer que chacun des blocs effectue une action bien précise. Pour
les blocs conditionnels (if, else...), il n’y a en général pas grand chose à dire de plus que l’algorithme lui-même. En
revanche, analyser les boucles for et while est essentiel, car l’action de ces boucles n’est pas forcément évidente en
première lecture. La notion essentielle pour montrer la correction des boucles est celle d’invariant de boucle.
Définition 2.1. Un invariant de boucle est une propriété dépendant des variables de l’algorithme, qui est vérifiée à
chaque passage dans la boucle.
Pour une formulation rigoureuse, on (je) préfère distinguer le cas des boucles while et celui des boucles for. Dans
les deux cas le principe est le même : une propriété dépendant des paramètres est un invariant de boucle si les deux
conditions suivantes sont vérifiées :
Boucles while. Pour la boucle while, le principe est exactement celui décrit ci-dessus : on exhibe une propriété
vraie avant la boucle, et qui, si elle est vérifiée en haut du corps de boucle, le sera en bas. On en conclut qu’elle est
vérifiée après.
(* Inv *)
while condition do
(* Inv *)
instructions
(* Inv *)
done ;
(* Inv *)
Boucles for. Pour la boucle for, l’invariant dépend en général du compteur de boucle. Pour celui-ci, le passage
i ← i + 1 (ou i − 1) est implicite car exécuté par la boucle elle-même. En fait, une boucle for est très semblable à une
boucle while particulière :
La séquence instructions dans les deux boucles est la même (en particulier, elle ne modifie pas i). Ainsi, sur la
boucle while, si on fait dépendre une propriété Inv de l’indice i (on identifie pour simplifier la référence et la valeur
pointée...), on s’aperçoit que, pour qu’elle soit un invariant, il faut que :
— Inv(id ) soit vérifié avant la boucle ;
— pour tout i entre id et if , si Inv(i) est vrai en haut du corps de boucle alors Inv(i + 1) est vrai juste avant
l’incrémentation de i.
On en déduit que Inv(if + 1) est vérifié après la boucle. C’est donc ainsi qu’on prouvera qu’une propriété est un
invariant d’une boucle for. Le principe est le même pour les boucles où l’indice est décrémenté à chaque passage :
pour une boucle de la forme for i=id downto if, on montre que Inv(id ) est vrai avant la boucle, et que pour tout
i ∈ {if , . . . , id }, si Inv(i) est vrai en haut du corps de boucle, alors Inv(i + 1) est vrai en bas du corps de boucle. On
en déduit que Inv(if + 1) est vrai après la boucle.
2.1.3 Complexité
Qu’est-ce que la complexité ? L’étude de la complexité d’une fonction consiste à estimer son coût (temporel ou
en mémoire), en fonction des entrées. Pour différencier deux entrées entre elles, on compare en général leur taille. Es-
sentiellement pour nous, les entrées seront constituées d’entiers ou de tableaux. Pour les tableaux, la donnée pertinente
est la taille. Pour les entiers, cela dépend du contexte. Pour un entier n, on peut en effet exprimer la complexité d’une
fonction dépendant de n en fonction :
— de l’entier n lui-même.
— ou de son nombre de chiffres (sa taille), correspondant à ln(n), à une constante multiplicative près. En général
on ne tient pas compte des constantes multiplicatives.
Par exemple, pour une fonction calculant n!, le nombre d’opérations est clairement linéaire en n. Pour exprimer
la complexité d’une fonction qui renvoie l’écriture en base 2 d’un nombre exprimé en base 10, on se dirigerait plus
naturellement vers log2 (n).
Coûts. Concentrons-nous d’abord sur la complexité en temps. L’exécution d’un algorithme est une séquence d’opé-
rations nécessitant plus ou moins de temps. Pour mesurer ce temps, on considère certaines opérations comme élémen-
taires : par exemple faire une opération arithmétique de base (addition, multiplication, soustraction, division...), lire
ou modifier un élément d’un tableau, ajouter un élément à la fin d’un tableau, affecter un entier ou un flottant, etc...
Estimer le coût d’une fonction sur une entrée de taille donnée signifie estimer le nombre de ces opérations élémentaires
effectuées par la fonction sur l’entrée. La complexité en mémoire consiste à estimer la mémoire nécessaire à une fonction
pour son exécution.
Différentes complexités. Les notions de complexité au programme sont les complexités temporelles dans le meilleur
et dans le pire cas, c’est à dire le nombre minimal/maximal que requiert l’algorithme pour s’exécuter sur une entrée de
taille n. La complexité dans le meilleur des cas n’est en général pas la plus pertinente. Par exemple pour la fonction de
recherche d’un élément x dans un tableau t, si l’élément cherché se trouve en première position, la fonction effectue (si
elle est codée de la bonne façon) un seul test d’égalité de la forme t.(i)=x. La complexité (en nombre de tests d’égalité)
est donc constante. La complexité au pire est le nombre d’opérations maximales nécessaires pour traiter une entrée
de taille donnée. Pour une fonction de recherche de x dans un tableau, le cas le pire se produit par exemple lorsque le
tableau ne contient pas x. Une autre notion de complexité intéressante est la complexité en moyenne, qu’on abordera
assez peu : elle demande des connaissances en probabilité, et une distribution de probabilités sur les entrées possibles.
En conclusion, lorsqu’on demandera d’exprimer la complexité d’une fonction sans plus de précision, on sous-entendra
la complexité dans le pire cas.
Complexité asymptotique et notations de Landau. Tout d’abord, lorsque l’on s’intéresse à la complexité
C(n) (n est la taille de l’entrée) d’une fonction, c’est bien souvent pour les grandes valeurs de n qu’il est pertinent de
connaître C(n), pour comparer vis à vis d’autres fonctions réalisant le même calcul. On cherche donc un comportement
asymptotique de n, qu’on rapportera aux fonctions usuelles : logarithmes, puissances, exponentielles... Ensuite, on ne
cherchera pas systématiquement un équivalent : celui-ci est souvent difficile à obtenir et n’est pas le plus important.
2
Si deux fonctions nécessitent respectivement 9n log(n) et n2 opérations élémentaires, on retiendra que la première
nécessite de l’ordre de n log(n) opérations, ce qui est bien meilleur que la seconde qui en requiert de l’ordre de n2 .
Rappelons les notations de Landau. Soit f et g deux fonctions N → R. On note :
— f (n) = O(g(n)), si il existe un entier n0 tel que g(n) est non nul pour n ≥ n0 et fg(n) (n)
est bornée.
n≥n0
— f (n) = Ω(g(n)), si g(n) = O(f (n)).
— f (n) = Θ(g(n)), si f (n) = O(g(n)) et f (n) = Ω(g(n)).
Pour exprimer la complexité C(n), on se contentera bien souvent d’un O. Par exemple, la recherche dichotomique
dans un tableau trié de taille n a une complexité de O(ln(n)). Si l’on veut préciser que cette borne est essentiellement
optimale, on dira que la complexité est en Θ(ln(n)). Attention à ne pas dire « la complexité est au moins en O(ln(n)) »,
ce qui n’aurait aucun sens.
Complexité arthmétique versus complexité binaire. On voit facilement que le calcul de xn avec l’algorithme
naïf nécessite O(n) multiplications, et O(log(n)) avc l’algorithme d’exponentiation rapide (voir la suite). Ainsi, la
complexité (au pire comme au mieux, puisqu’il n’y a qu’une seule entrée de « taille » n ici) est en O(n) ou O(log(n)).
Ce raisonnement est exact, en n’oubliant pas que l’on parle ici de complexité arithmétique. En pratique, les entiers
intervenant dans le calcul grandissent vite : tenir compte de cette croissance donnerait une complexité dite binaire.
On s’intéressera uniquement à la complexité arithmétique, mais il faut avoir à l’esprit que cela ne reflète pas forcément
fidèlement les temps de calculs effectifs.
Algorithme naïf. Il consiste simplement à multiplier n fois une variable par x, en partant de 1.
Algorithme d’exponentiation naïf
let expo x n=
let y=ref 1 in
(* Inv(0): y=x^0 *)
for i=0 to n-1 do
(* Inv(i): y=x^i *)
y:=!y*x
(* Inv(i+1): y=x^(i+1) *)
done ;
(* Inv(n): y=x^n *)
!y
;;
La terminaison est évidente : l’algorithme repose sur une boucle for. Pour la correction, il est simple d’exhiber
un invariant : la propriété y = xi est conservée à chaque passage de boucle. Plus exactement, la propriété au rang 0
est vérifiée avant la boucle, et on montre facilement que si elle est vérifiée au rang i en haut du corps de boucle, elle
est vérifiée au rang i + 1 en bas du corps de boucle. On en déduit que y = xn après la boucle, et on renvoie y donc
l’algorithme est correct. Notez que là encore, dans la preuve de complexité, on a identifié référence et valeur pointée,
ce qui est quand même plus pratique.
En terme de complexité arithmétique, celle-ci est clairement de n multiplications.
Algorithme d’exponentiation rapide. L’algorithme d’exponentiation rapide (itératif) se base sur ldécomposition
2
binaire de l’entier n. Prenons un exemple : on souhaite calculer x11 . 11 s’écrit en binaire 1011 . À partir de x et
p
en procédant par élévations au carré successives, il est facile de calculer les x2 : ici ce sont x, x2 , x4 et x8 . Comme
2
11 = 1011 , il suffit de multilplier x8 , x2 et x pour obtenir x11 .
Expliquons son fonctionnement : il suit de près l’algorithme permettant de récupérer les bits d’un entier par division
successives par 2. Cet algorithme permet de récupérer les bits 1 par 1, en commençant par les bits de poids faibles. En
p
utilisant une variable annexe que l’on élève au carré à chaque étape, on calcule successivement les x2 . Il suffit alors
p
de multiplier une variable (z dans le code suivant) initialisée à 1 par les x2 qui conviennent (donnés par les bits de
n) pour obtenir xn . Voici le code Caml :
Algorithme d’exponentiation rapide
let expo_rapide x n=
let m=ref n and y=ref x and z=ref 1 in
(* Inv: z*y^m = x^n *)
while !m > 0 do
(* Inv *)
if !m mod 2=1 then z:=!z * !y ;
m:=!m/2 ;
y:=!y * !y
(* Inv *)
done ;
(* Inv *)
!z
;;
La terminaison de l’algorithme vient du fait que les valeurs prises par m (on identifie encore référence et valeur)
forment une suite positive strictement décroissante. L’invariant de boucle while est le suivant : à chaque passsage
dans la boucle, zy m = xn . En effet, cette propriété est vérifiée avant la boucle, et pour voir qu’elle est conservée lors
d’un passage dans la boucle, il suffit de distinguer suivant la parité de m. À la fin de l’algorithme, puisque m est nul
(on est sorti de la boucle), alors z = xn , donc l’algorithme renvoie bien xn . En terme de complexité, on fait un nombre
borné de multiplications (1 ou 2, en fait) à chaque passage de boucle, et il est facile de voir que le nombre de passages
dans la boucle est égal au nombre de chiffres dans l’écriture en binaire de n (0 si n est nul), c’est à dire O(log(n)), qui
est la complexité de l’algorithme en nombre d’opérations arithmétiques.
let mini t=
let m=ref t.(0) in
for i=1 to [Link] t - 1 do
m:= min !m t.(i)
done ;
!m
;;
let appartient t x=
let b=ref false in
for i = 0 to [Link] t - 1 do
b:= !b || t.(i) = x
done ;
!b
;;
let occurences t x=
let c=ref 0 in
for i=0 to [Link] t -1 do
c:= !c + (if t.(i) = x then 1 else 0)
done ;
!c
;;
Remarquez le caractère paresseux de l’opérateur || dans la fonction appartient : on n’évalue pas t.(i)=x si !b est
true. Pour éviter de parcourir toute le tableau pour vérifier si au moins un élément du tableau vérifie une certaine
propriété, on peut utiliser une référence vers un booléen et une boucle while :
let appartient t x=
let b=ref false and i=ref 0 in
while not !b && !i< [Link] t do
b:=t.( !i) = x
done ;
!b
;;
La portion d − g sur laquelle on travaille a initialement une taille t0 = n (la taille du tableau), et diminue au
moins de moitié à chaque itération : tn+1 ≤ tn /2. Ceci montre que seulement O(log n) étapes sont nécessaires pour
que l’algorithme s’arrête.
Idée du tri. L’idée est simple : supposons qu’un tableau de taille n est déja en partie trié avec ses i premiers
éléments à leur place définitive. On sélectionne le plus petit des n − i éléments restants, qu’on amène en position i + 1.
Le tableau a alors ses i + 1 premiers éléments à leur position définitive. Itérer ce procédé n − 1 fois suffit pour trier le
tableau. La fonction suivante nous sera utile :
échange de deux éléments d’un tableau
let echange t i j=
let a=t.(i) in
t.(i) <- t.(j) ;
t.(j) <- a
;;
Naturellement, echange possède le type : 'a array -> int -> int -> unit.
Code Caml.
Le tri par sélection
let tri_selection t =
let n=[Link] t in
for i=0 to n-2 do
(* Inv(i): t.(0),...,t.(i-1) dans l'ordre croissant est plus petits que les autres éléments de t *)
let imin=ref i in
for j=i+1 to n-1 do
(* Inv2(j): i_min est l'indice du plus petit élément parmi t.(i),...,t.(j-1) *)
if t.(j) < t.( !imin) then imin:= j
(* Inv2(j+1) *)
done ;
if !imin>i then echange t i !imin
(* Inv(i+1) *)
done ;;
Terminaison de l’algorithme. L’algorithme de tri par sélection est constitué de deux boucles for imbriquées, il
termine donc !
Preuve de l’algorithme. La boucle for interne a pour effet de positionner la variable i_min à l’indice de l’élément
minimal du tableau entre les indices i et n-1. Ainsi, un passage dans la boucle for externe positionne l’élément
minimal du tableau entre les indices i et n-1 en position i. Cette boucle for principale possède l’invariant suivant :
Invi : Les éléments du tableau entre les indices 0 et i-1 sont triés dans l’ordre croissant et plus petits que les autres.
— Tout d’abord, Inv0 est vrai : en effet, le sous tableau t[0:0] est vide.
— Clairement, si Invi est vrai en haut du corps de la boucle, Invi+1 est vrai en bas du corps de boucle : en effet,
on positionne le plus petit élément de parmi t.(i),...,t.(n-1) en position i.
Le compteur de boucle i prend toutes les valeurs entre 0 et n − 2. Par suite, l’invariant Invn−2+1 = Invn−1 est
vérifié en sortie de boucle, ce qui implique que les n − 1 premiers éléments du tableau sont triés en sortie de boucle, et
plus petits que l’autre élément du tableau, à savoir t.(n-1). Ainsi, le tableau est entièrement trié en sortie de boucle,
donc en sortie de fonction, et le tri est correct.
En toute rigueur, il faudrait exhiber un invariant de boucle pour la boucle for interne, celui-ci est plutôt évident
et est marqué dans le code.
Idée du tri : On maintient constamment la partie gauche du tableau trié. Lorsqu’on considère un nouvel élément
x (celui juste à droite de la partie triée), il faut le faire « descendre » de façon à ce que cette portion augmentée de 1
élément soit triée. Pour ce faire, plutôt que de procéder par échanges, on sauvegarde la valeur de l’élément dans une
variable. Il suffit ensuite de faire monter un à un les éléments du tableau tant qu’ ils sont plus grands que x. Une fois
ceci effectué, on peut positionner x.
Code Caml.
Le tri par insertion
let tri_insertion t =
let n=[Link] t in
for i=1 to n-1 do
(* t.(0),..,t.(i-1) triés *)
let x=t.(i) and j=ref i in
while !j>0 && t.( !j-1)> x do
(* Inv: Pour tout k vérifiant j<k<=i, L[k]>x *)
t.( !j) <- t.( !j-1) ;
decr j
(* Inv *)
done ;
t.( !j) <- x
(* Inv(i+1) *)
done ;;
Terminaison de l’algorithme. L’algorithme de tri par insertion est constitué d’une boucle while dans une boucle
for. Il faut donc montrer que pour tout i ∈ {1, . . . , n − 1}, la boucle while termine, ce qui est à peu près évident : la
variable j (on identifie encore référence et valeur) est initialisée à i juste avant la boucle, la condition de continuation
du while comporte notamment la condition j > 0 et j est décrémenté à chaque tour de boucle. Notons que les indices
du tableau considérés ne produisent jamais d’erreurs (d’accès en dehors du tableau). Remarquez que si j = 0, au
niveau de la condition du while, alors la condition !j>0 n’est pas vérifiée et on n’a pas besoin d’évaluer t.(!j-1)> !x
(qui produirait un dépassement d’indice) pour s’apercevoir que la condition !j>0 && t.(!j-1)> !x est fausse. Ceci
est dû au comportement paresseux de l’opérateur logique &&.
En sortie de boucle while, la condition !j>0 && t.(!j-1)> !x est fausse, ainsi la boucle for possède l’invariant
suivant :
En effet :
— Lorsque i vaut 1, t.(0) tout seul forme bien un ensemble trié dans l’ordre croissant, donc Inv1 est vrai avant la
boucle.
— Si, pour i ∈ {1, . . . , n − 1}, Invi est vrai en haut du corps de boucle, alors Invi+1 est vrai en bas du corps de
boucle. En effet, après l’exécution de la boucle while, les éléments de t.(k) pour j < k ≤ i sont strictement
supérieurs à x et ceux avant l’indice j (exclus) sont inférieurs ou égaux (avec j éventuellement nul). Ainsi, placer
x en position i dans t mène à la portion triée t.(0),...,t.(i+1).
Par suite, après l’exécution de la boucle for les éléments t.(0),...,t.(n-1) sont dans l’ordre croissant, et la fonction
est correcte.
Avertissement : piles et files bornées ou non. De par la finitude de la mémoire utilisant une structure de pile
ou de file, le nombre d’éléments que l’on peut mettre dans une pile ou une file est nécessairement borné. Toutefois, la
borne est en général conséquente, si bien que l’on peut considérer que la capacité (nombre d’éléments que l’on peut
stocker) est infinie : on parle de pile (ou file) non bornée. On va ici implémenter des stucture de pile et de file dans
laquelle la capacité est fixée une fois pour toute à la création de l’objet : la pile (ou la file) est bornée. La fonction de
création prendra donc en paramètre la capacité. L’explication est simple : on va utiliser des tableaux, dont la taille est
elle-même fixée une fois pour toute : la taille du tableau sera égale à la capacité choisie 1 .
2.3.1 Piles
Rappels sur la structure
On rappelle qu’une pile suit le principe « LIFO » : dernier arrivé, premier sorti. Les opérations à écrire pour
implémenter une structure de pile sont les suivantes :
— création d’une pile vide ;
— test d’égalité au vide ;
— accès au sommet d’une pile non vide ;
— retrait de l’élément au sommet d’une pile non vide ;
— ajout d’un élément au sommet de la pile (non pleine ici).
sommet
0 1 2 3 4 5 6 7 8
Figure 2.1 – Le tableau contenu associé à une pile de capacité 9, comportant 5 éléments. Le fond de la pile est à
l’indice 0 (à gauche), le sommet à l’indice 4. Les éléments grisés sont ceux de pile, les éléments blancs sont quelconques.
L’ajout d’un élément se ferait à l’indice 5 (il faut aussi incrémenter nb) et pour retirer un élément de la pile, il suffit
de diminuer nb.
Remarque 2.2. Le champ capacite est superflu, car la capacité d’une pile peut s’obtenir comme la longueur du
tableau contenu. Néanmoins l”implémentation est plus claire avec ce champ supplémentaire.
Implémentation concrète
On décide de donner un type pile polymorphe, le type est fixé à la création et la fonction de création prend donc
deux paramètres : la capacité et un élément permettant de créer le tableau (néanmoins la pile est vide au départ).
type 'a pile = {capacite: int ; mutable nb: int ; contenu: 'a array} ;;
1. On pourrait implémenter une structure non bornée à l’aide d’un tableau redimensionnable, similaire aux listes Python (elles se
comportent comme des tableaux mais on peut rajouter un élément avec append). Cette structure n’existe pas en Caml, mais on peut
l’implémenter nous même !
# let p=creer_pile 5 0 ;;
val p : int pile = {capacite = 5; nb = 0; contenu = [|0; 0; 0; 0; 0|]}
# empiler p 1 ;;
- : unit = ()
# empiler p 2 ;;
- : unit = ()
# empiler p 3 ;;
- : unit = ()
# depiler p ;;
- : int = 3
# p ;;
- : int pile = {capacite = 5; nb = 2; contenu = [|1; 2; 3; 0; 0|]}
Attention : la pile p ne possède que deux éléments à la fin du processus, les deux premiers du tableau contenu. Les
trois suivants ne font pas partie de la pile.
Complexité
À part à la création, toutes les opérations se font en temps constant (O(1)). La création est en O(c), la capacité
de la pile.
2.3.2 Files
Rappels sur la structure
On rappelle qu’une file suit le principe « FIFO » : premier arrivé, premier sorti. Les opérations à écrire pour
implémenter une structure de pile sont les suivantes :
— création d’une file vide ;
— test d’égalité au vide ;
— retrait de l’élément en tête d’une file non vide ;
— ajout d’un élément en queue d’une file (non pleine ici).
0 1 2 3 4 5 6 7 8 0 1 2 3 4 5 6 7 8
Figure 2.2 – Deux tableaux associés à des files de capacité 9, comportant 5 éléments. Les éléments effectivement
présents dans la file (grisés) sont entre les indices tete (inclus) et queue (exclus), le tableau est considéré comme
circulaire : lorsqu’un élément est enfilé, le champ queue est incrémenté de 1 (modulo la capacité), et lorsqu’un élément
est défilé, c’est le champ tete.
Remarque 2.3. Là encore, le champ capacite est superflu. Il en va de même d’un des deux champs tete ou queue
car la relation suivante est toujours vérifiée :
Par contre, le champ nb ne peut s’obtenir à partir de tete et queue, car lorsque ces deux indices sont égaux, c’est nb
qui permet de faire la distinction entre une file pleine et une file vide.
Implémentation concrète
type 'a file={capacite: int; mutable nb: int; mutable tete: int; mutable queue: int; contenu: 'a array} ;;
# let f=creer_file 5 0 ;;
val f : int file =
{capacite = 5; nb = 0; tete = 0; queue = 0; contenu = [|0; 0; 0; 0; 0|]}
# for i=1 to 3 do enfiler f i done ;;
- : unit = ()
# defiler f ;;
- : int = 1
# f;;
- : int file =
{capacite = 5; nb = 2; tete = 1; queue = 3; contenu = [|1; 2; 3; 0; 0|]}
Les éléments présents dans la file (2 et 3), sont bien entre l’indice de tête inclus et l’indice de queue exclu.
Complexité
Là encore, toutes les opérations se font en temps constant O(1), exceptée la création en O(c), la capacité de la file.
Chapitre 3
Récursivité et listes
qui fonctionne tout aussi bien ! Remarquez l’usage de rec lors de la définition, qui indique au compilateur Caml que
l’objet qu’on définit est une fonction récursive 1 . On distingue clairement deux cas dans cette fonction :
— le cas n = 0, appelé cas terminal ;
— le cas n > 0, qui produit un appel récursif à la fonction fact_rec.
En particulier, lors d’appels imbriqués c’est-à-dire lorsqu’une fonction f appelle une fonction g, ce qui est relatif à
l’appel de la fonction g est placé juste au dessus de ce qui est relatif à la fonction f . Lorsque g termine son exécution,
ce qui est relatif à l’exécution de g est dépilé. Comme l’adresse de retour est contenu dans la pile d’appel, l’exécution
de f peut reprendre juste après l’endroit où g a été appelée.
Une fonction récursive est essentiellement une fonction qui s’appelle elle-même, ainsi les appels successifs à f
s’empile dans la pile d’appels (voir figure 3.1).
0 1
1 1 1 1
2 2 2 2 2 2
3 3 3 3 3 3 3 6
4 4 4 4 4 4 4 4 4 24
5 5 5 5 5 5 5 5 5 5 5 120
Figure 3.1 – La pile d’exécution (partie relative à l’exécution de fact_rec 5) : en clair les paramètres d’appels, en
gras les valeurs de retour.
Une fois arrivé à un cas terminal (ne produisant pas d’appel récursif), le nombre d’éléments de la pile d’appels
se réduit. Dans le cas de la fonction factorielle, comme celle-ci ne rappelle qu’une fois, dés lors qu’on a commencé à
dépiler on ne s’arrête plus (ce moment correspond au milieu de la figure 3.1). Enfin, la récursion s’arrête lorsqu’on
dépile l’élément correspondant au premier appel de la fonction. On peut vérifier ce comportement en traçant la fonction
fact_rec : cela permet d’imprimer à l’écran les entrées et sorties d’une fonction :
# #trace fact_rec ;;
fact_rec is now traced.
# fact_rec 5 ;;
fact_rec <-- 5
fact_rec <-- 4
fact_rec <-- 3
fact_rec <-- 2
fact_rec <-- 1
fact_rec <-- 0
fact_rec --> 1
fact_rec --> 1
fact_rec --> 2
fact_rec --> 6
fact_rec --> 24
fact_rec --> 120
- : int = 120
On voit que le nombre d’appels imbriqués réalisés par une fonction récursive peut être important : il faut stocker
ces appels, ce qui est coûteux en mémoire. En pratique, on peut voir la pile d’appels comme une pile de capacité finie :
si la pile est pleine, un appel supplémentaire produit un dépassement de capacité : le fameux stack overflow en anglais.
Cette fonction est très proche de la fonction factorielle écrite plus haut. Testons quelques entiers :
#somme 100 ;;
- : int = 5050
#somme 1000 ;;
- : int = 500500
# somme 1000000 ;;
Stack overflow during evaluation (looping recursion?).
Dans le dernier exemple, la capacité de la pile d’appels a été dépassée 3 : le calcul n’a pu aboutir par manque de
mémoire.
Écrivons une autre fonction :
let rec somme_avec_acc n acc=match n with
| 0 -> acc
| _ -> somme_avec_acc (n-1) (n+acc)
;;
Cette fonction utilise un accumulateur acc, dans lequel se font les additions. Observons quelques appels avec acc=0 :
#somme_avec_acc 100 0 ;;
- : int = 5050
#somme_avec_acc 1000 0 ;;
- : int = 500500
#somme_avec_acc 1000000 0 ;;
- : int = 500000500000
Cette fonction permet donc de calculer la somme des entiers de 0 à n, mais ne semble pas souffrir du grossissement de
la pile d’appels. En effet, cette fonction est récursive terminale.
Par exemple :
#pgcd 1898615 16586155318 ;;
- : int = 1
3. L’interpréteur OCaml se demande si cela est du à une fonction récursive qui ne termine pas : ce n’est pas le cas ici !
4. Python, par exemple, ne gère pas la récursivité terminale.
Algorithme d’exponentiation rapide. L’algorithme d’exponentiation rapide se reformule très facilement en fai-
sant usage de récursivité. En effet, pour n ≥ 0, on a :
1
2
si n = 0;
xn = xn/2 si n est pair;
x × xn/2 2
sinon.
On en déduit le code :
let rec expo_rapide x n=match n with
| 0 -> 1
| _ -> let y=expo_rapide x (n/2) in if n mod 2 = 0 then y*y else y*y*x
;;
Cette fonction n’est pas récursive terminale, ce qui n’est pas gênant ici : seulement O(log n) appels récursifs sont
effectués.
Testons :
#pair 5 ;;
- : bool = false
#pair 6 ;;
- : bool = true
Cet exemple est assez artificiel, le suivant l’est un peu moins. On suppose les variables globales u0 et v0 définies, ce
sont deux flottants positifs.
let rec u n=match n with
| 0 -> u0
| _ -> (u (n-1) +. v(n-1))/. 2.
and v n=match n with
| 0 -> v0
| _ -> sqrt (u (n-1) *. v(n-1))
;;
Ces fonctions définissent deux suites (un )n∈N et (vn )n∈N . Un exercice classique de mathématiques consiste à mon-
trer qu’elles sont adjacentes, leur limite commune est appelée la moyenne arithmético-géométrique de u0 et v0 . La
convergence est très rapide :
#u 3 ;;
- : float = 2.24303398875
#v 3 ;;
- : float = 2.24302317183
#u 4 ;;
- : float = 2.24302858029
#v 4 ;;
- : float = 2.24302858028
On a pris ici u0 = 1 et v0 = 4. Notons que les deux fonctions u et v ont malheureusement une grande complexité,
comme expliqué dans la sous-section suivante.
Pour prendre en compte la récursivité croisée, on peut donner une autre définition de la récursivité 5 :
Définition 3.2. Une fonction f est dite récursive lorsque dans la pile d’appels peuvent se trouver simultanément
plusieurs appels à f .
5. qui vaut ce qu’elle vaut...
Le problème de la fonction précédente est sa complexité, qui réside dans le nombre d’appels récursifs effectués. Notons
An le nombre d’appels récursifs nécessaires pour le calcul de Fn . Alors, la suite (An ) vérifie la relation de récurrence
A0 = A1 = 0 et pour tout n ≥ 2, An = 2 + An−1 + An−2 , soit An + 2 = (An−2 +√2) + (An−1 + 2). Autrement dit, la
suite (An + 2)n∈N coïncide avec la suite (2Fn )n∈N , d’où An = Θ(ϕn ), avec ϕ = 1+2 5 > 1. La complexité de la fonction
fibo est donc exponentielle en n, ce qui n’est pas étonnant si on schématise les appels récursifs effectués, comme en
figure 3.2.
n−1 n−2
.. .. .. .. .. .. .. ..
. . . . . . . .
1 0 1 0
On parle ici de chevauchement des appels récursifs : la fonction, bien que correcte, nécessite de réaliser plusieurs
fois les mêmes calculs pour aboutir. Elle est impraticable pour des grands n. Déja le calcul de F40 nécessite plus de
15 secondes sur mon ordinateur personnel, et chaque incrémentation de n demande une multiplication du temps de
calcul par environ 1.618.
# let a=[Link]() in let _ = fibo 40 in [Link]() -. a ;;
- : float = 15.7569840000000028
# let a=[Link]() in let _ = fibo 41 in [Link]() -. a ;;
- : float = 25.4530430000000081
Le même phénomène fait que les fonctions u et v de la sous-section précédente sont inefficaces. Donnons deux méthodes
pour éviter cet écueil :
— utiliser une fonction itérative, mais on perd la formulation récursive ;
— utiliser un dictionnaire pour stocker les valeurs déja calculées.
En conclusion, il faut faire attention à ne pas faire des appels récursifs qui se recoupent, sous peine de voir la complexité
exploser !
1
2
3
4
5
6
7
Figure 3.3 – Le jeu de Hanoï : comment déplacer les 7 disques du piquet A au piquet C, en suivant les règles ?
1
2 2
3 3 1 3 2 1
1 1
3 2 2 3 1 2 3
1
2 2
1 3 3
On cherche à donner les mouvements de disques à effectuer pour résoudre le jeu. De manière itérative, il n’est pas
évident à résoudre, mais il est très facile de le faire lorsqu’on pense à la récursivité. Soient i, j et k trois caractères
tels que {i, j, k} = {A, B, C}, et n ∈ N. Pour faire passer n disques du piquet i au piquet j :
— il n’y a rien à faire si n = 0 ;
— pour n ≥ 1, il suffit de faire passer les n − 1 disques numérotés de 1 à n − 1 du piquet i au piquet k, de déplacer
ensuite le disque n du piquet i au piquet j, puis de refaire passer les n − 1 disques du piquet k au piquet j. Le
fait de travailler avec les disques les plus petits permet de ne pas violer la deuxième règle.
Écrivons donc une fonction Caml qui imprime à l’écran la suite des mouvements à effectuer pour résoudre le jeu. Un
mouvement est décrit comme i -> j, ce qui signifie faire passer le disque supérieur du piquet i au piquet j (l’opérateur
^ permet la concaténation de chaînes de caractères) :
let deplacement i j=
print_string (i^" -> "^j^"\n")
;;
La discussion précédente invite à écrire une fonction hanoi n résolvant le jeu à n disques, qui fait un unique appel
à une fonction récursive interne aux n i j k qui doit donner la suite des mouvements permettant de faire passer les
n plus petits disques du piquet i au piquet j, avec {i, j} ⊂ {A, B, C}. Pour des raisons de commodité, il est pratique
d’indiquer le dernière lettre parmi {A, B, C} dans une variable (k) :
let hanoi n=
let rec aux i j k n=match n with
| 0 -> ()
| _ -> aux i k j (n-1) ; deplacement i j ; aux k j i (n-1)
in aux "A" "C" "B" n ;
;;
Testons avec n = 3 :
>>> hanoi 3
A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C
On retrouve les mouvements de la figure 3.4. Il est facile de montrer par récurrence que le nombre de mouvements
produits est 2n − 1 : c’est optimal 6 .
Comme on le voit sur cet exemple, les fonctions où plusieurs appels récursifs sont nécessaires ne sont pas vraiment
faciles à traduire de façon itérative (à moins d’utiliser une pile pour essentiellement réécrire la récursivité...) : c’est un
avantage de l’emploi de fonctions récursives.
3.4.1 Définition
Définition 3.3. Une liste chaînée d’éléments de type t est :
— soit la liste vide (souvent notée []) ;
— soit la donnée d’un élément x de type t et d’une liste chaînée ` d’éléments de type t, qu’on notera Cons(x, `).
Dans la définition précédente, on appelle x la tête de liste, et ` la queue de la liste (c’est une liste !). Cette manière
récursive de définir le type liste chaînée est essentiellement ce qu’on va faire en Caml. Une définition équivalente (mais
inductive) est la suivante :
Définition 3.4. L’ensemble des listes chaînées d’éléments de type t est le plus petit ensemble C tel que :
— la liste vide [] appartient à C ;
— si x est de type t et ` une liste chaînée d’éléments de type t, alors Cons(x, `) appartient à C.
12 99 37 8
6. La fonction a aussi une complexité en O(2n ), ce qui est exponentiel... Mais là on ne peut pas faire mieux : ce qu’on cherche à calculer
est de taille exponentielle en n.
On remarque que le type est int list, les listes chaînées en Caml sont constituées d’éléments homogènes, comme les
tableaux. Voici comment obtenir une liste chaînée à partir d’une autre et d’un nouvel élément, en suivant la définition :
#12::[99; 37; 8] ;;
- : int list = [12; 99; 37; 8]
L’opérateur :: (qui se lit « Conse ») est un opérateur infixe, x::q donne une 'a list si x est de type 'a et q de type
'a list. La version préfixe permet de s’en convaincre 7 :
Inversement, à partir d’une liste non vide, on peut accéder à sa tête et à sa queue via les fonctions hd (head) et tl
(tête) du module List :
# [Link] ;;
- : 'a list -> 'a = <fun>
# [Link] ;;
- : 'a list -> 'a list = <fun>
# let q=["a"; "b"; "c"] ;;
val q : string list = ["a"; "b"; "c"]
# [Link] q ;;
- : string = "a"
# [Link] q ;;
- : string list = ["b"; "c"]
Ceci dit, les fonctions hd et tl sont rarement utilisées, on préfère fonctionner par filtrage sur les listes : l’opérateur ::
peut être utilisé comme motif de filtrage, voici comment recoder hd et tl :
Complexité des opérations. Les trois opérations hd, tl et :: ont toute une complexité constante. Ceci peut
paraître étonnant car on crée de nouvelles listes via ces opérations, mais elles ne sont pas recopiées entièrement : les
éléments sont partagés au maximum pour diminuer la complexité (en temps comme en mémoire), voir figure 3.6.
q2 24 12 99 37 8
q3 1 q q1
Figure 3.6 – La liste q=[12; 99; 37; 8], et les listes q1=[Link] q, q2=24::q et q3=1::q
Résumé et avertissement. On n’a parlé que de l’élément de tête dans cette présentation : en effet, lorsqu’on
manipule une liste chaînée, seul l’élément en tête de liste est accessible. Les listes chaînées sont très différentes des
tableaux (array) :
— un tableau est de taille fixée, et on peut accéder et modifier ses éléments en temps constant (on rappelle que l’on
parle de structure mutable) ;
— une liste est immuable (ou non mutable, ou persistante), l’accès à la tête se fait en temps constant, de même que
la construction de nouvelles listes avec [Link] et ::.
On fera donc très attention à ne pas confondre les deux structures.
Elle se contente de parcourir la liste passée en paramètre, en entier, et ne fait rien d’autre. Mais elle est typique d’une
fonction sur une liste :
— cette fonction est récursive ;
— le filtrage comporte le motif de la liste vide [], et le motif d’une liste non vide x::p.
Longueur d’une liste. La fonction suivante donne la longueur d’une liste (qui existe déja en OCaml : [Link]).
let rec longueur q=match q with
| [] -> 0
| _::p -> 1 + longueur p
;;
Attention : la complexité de cette fonction est linéaire en la taille de la liste (c’est le cas de [Link] également,
mais la version Caml est récursive terminale).
Tester l’appartenance d’un élément à une liste. La fonction suivante implémente la fonction [Link] de
Caml 8 .
let rec appartient x q=match q with
| [] -> false
| y::p -> y=x || appartient x p
;;
Un exemple un peu plus complexe. L’opérateur :: peut être utilisé plusieurs fois dans un motif de filtrage. La
fonction suivante teste si une liste possède deux éléments consécutifs égaux.
let rec deux_egaux q = match q with
| [] | [_] -> false
| x::y::p when x=y -> true
| x::p -> deux_egaux p
;;
Miroir d’une liste. La fonction [Link] de Caml renvoie la liste « miroir » de la liste passée en argument. En
voici une implémentation, où on utilise une fonction auxiliaire et un accumulateur qu’on remplit au fur et à mesure
que l’on lit la liste passée en entrée.
let miroir q=
let rec aux acc q=match q with
| [] -> acc
| y::p -> aux (y::acc) p
in aux [] q
;;
Fonction récursive. La fonction suivante convient, par exemple. On se force à écrire une fonction récursive terminale
ici.
let liste_ent n =
let rec aux acc p = match p<0 with
| true -> acc
| false -> aux (p::acc) (p-1)
in aux [] (n-1)
;;
Fonction itérative. Une autre possibilité est d’utiliser une boucle, et une référence vers une liste (rappel : une liste
est immuable, par contre on peut changer l’objet pointé par une réféfence).
let liste_ent n =
let q = ref [] in
for i = n-1 downto 0 do
q:= i :: !q
done ;
!q
;;
On suit ici la définition 3.3. Voici la définition du type et de l’opérateur cons et des fonctions tete et queue, analogues
de ::, [Link] et [Link] :
let tete q=match q with
| Vide -> failwith "Vide"
| Cons (x, _) -> x
;;
C’est quasiment de cette manière que sont implémentées les listes en Caml, on remarquera que hd, tl et la version
préfixe de :: ont les mêmes types que ceux-ci dessus, en remplaçant liste par list.
#[] ;;
- : 'a list = []
Structure de pile
La structure de pile avec son seul sommet accessible est très similaire à une liste chaînée avec son élément de tête
accessible. Ainsi une liste chaînée est quasiment une pile ! On réalise ici une structure mutable, constituée d’un unique
champ (modifiable) contenant une liste.
type 'a pile = {mutable contenu: 'a list} ;;
La seule différence avec le type des opérations de pile écrits pour des tableaux est la création, qui ne prend pas
d’argument :
let creer_pile () = {contenu=[]} ;;
let pile_vide p = [Link] = [] ;;
let empiler p x=[Link] <- x::[Link] ;;
Structure de file
Implémenter une structure de file à l’aide de listes n’est pas aussi simple qu’une structure de pile : en effet, seule
la tête de liste est accessible. Mais on peut en utiliser deux ! Cela mène au type suivant :
type 'a file = {mutable entree: 'a list ; mutable sortie: 'a list} ;;
La première est utilisée pour rajouter des éléments (en queue de file), la seconde pour enlever des éléments (en tête
de file), comme le montre la figure suivante :
Figure 3.7 – Réalisation d’une file à l’aide de deux listes. Le sens de la file est indiqué par une flêche.
La seule difficulté dans l’écriture des opérations se situe dans l’opération « défiler », lorsque la seconde liste est
vide : c’est l’élément en bout de la première liste qui doit sortir. Pour garder une complexité (presque) constante, il
suffit d’abord de remplacer la deuxième liste (vide) par la première, retournée (et la première par une liste vide). Voici
les opérations associées :
Complexité des opérations. Toutes les opérations se font en temps constant, sauf lorsqu’il faut défiler alors que
la seconde liste est vide. Dans ce cas, la complexité est linéaire en le nombre d’éléments présents dans la file, ce qui est
fâcheux. Toutefois, ce cas-là ne se produit pas trop souvent. En effet lorsqu’on défile alors qu’il y a n éléments dans
la file (et que la seconde liste est vide), le coût est O(n), mais néanmoins les n − 1 prochaines opérations « défiler »
se feront en temps constant : sur ces n opérations, il y a un coût total O(n), donc O(1) en moyenne. On dit que la
complexité amortie de l’opération « défiler » est O(1).
# let f=creer_file () ;;
val f : '_a file = {entree = []; sortie = []}
#for i=0 to 999 do enfiler f i done ;;
- : unit = ()
#defiler f ;; (* cette opération est coûteuse *)
- : int = 0
#f ;; (* on peut défiler 999 fois en temps O(1) *)
- : int file =
{entree = []; sortie = [1; 2; 3; 4; 5; 6; 7; 8; 9;...]}
Remarque 3.5. De manière générale, on peut de la même façon réaliser une structure de file dès que l’on a une
implémentation d’une structure de pile, il suffit d’en utiliser deux.
Écriture en Caml. Pour pouvoir écrire la fonction de tri, il faut au préalable écrire deux fonctions :
— l’une (fission), qui divise une liste en deux listes de même taille à un élément près ;
— l’autre (fusion), qui prend en entrée deux listes triées dans l’ordre croissant, et renvoie la liste triée constituée
des éléments des deux listes.
Voici des implémentations possibles :
Une fois ces deux fonctions écrites, l’implémentation du tri est un jeu d’enfant (attention à ne pas oublier le cas
terminal) :
Complexité. L’intérêt d’un tel tri par rapport aux tris déja vus (tri par insertion, tri par sélection...) paraît obscur.
Néanmoins on montrera dans le chapitre suivant que la complexité du tri fusion est O(n log n) avec n la taille de la
liste à trier : c’est un gain considérable par rapport aux tris évoqués, quadratiques (de complexité O(n2 )).
Tri d’une liste en OCaml. La fonction [Link] de Caml trie une liste, avec le tri fusion ! Elle est très semblable
à l’implémentation donnée précédemment, mais un peu plus générique car elle prend en paramètre une fonction de
comparaison. C’est une fonction curryfiée f x y à deux arguments et à valeurs dans les entiers. La liste renvoyée est
telle que f (x, y) ≤ 0 si x est placé avant y. Par exemple pour trier une liste d’entiers (ou de flottants) suivant l’ordre
usuel :
Chapitre 4
4.1 Introduction
On rappelle le code Caml de la fonction donnant la factorielle :
Cette fonction est très proche de la définition mathématique associée et il est facile de voir qu’elle termine pour
tout entier positif passé en argument. En fait, elle termine car il n’existe pas de suite strictement décroissante dans
N, et elle est correcte par principe de récurrence : le cas terminal est correct car 0! = 1, et ensuite une récurrence
immédiate sur N prouve que la fonction calcule bien n!. En ce qui concerne sa complexité (arithmétique), elle vérifie
une relation de récurrence de la forme C(n) = C(n − 1) + O(1), dont la solution est C(n) = O(n).
Sur cet exemple simpliste sont résumés les trois principes de l’analyse des fonctions récursives :
— la terminaison se montre en exhibant une quantité, fonction des paramètres de la fonction récursive, et dont les
valeurs décroîssent à chaque appel récursif. Ces valeurs sont dans un ensemble muni d’un ordre bien fondé, pas
nécessairement N.
— la correction, en général assez immédiate, repose sur un principe proche du principe de récurrence ;
— la complexité s’étudie en évaluant la formule récurrente donnée par le coût du traitement dans le corps de la
fonction auquel s’ajoute le coût des appels récursifs.
4.2 Terminaison
Définition 4.1. Une relation d’ordre sur un ensemble E est une relation :
— réflexive : pour tout x ∈ E, x x.
— transitive : pour tout x, y, z ∈ E, si x y et y z, alors x z.
— antisymétrique : pour tout x, y ∈ E, si x y et y x alors x = y.
Dans la définition précédente, si pour toute paire x, y d’éléments, on a soit x y soit y x, alors la relation
d’ordre est dite totale.
Définition 4.2. Un élément minimal de E est un élément x tel que y x ⇒ x = y. Un plus petit élément de E
(nécessairement unique) est un élément x tel que x y pour tout y de E.
Définition 4.3. Un ensemble ordonné (E, ) est dit bien fondé (on dit aussi que c’est l’ordre qui est bien fondé)
s’il n’existe pas de suite de cet ensemble strictement décroisante (pour ). Si de plus, l’ordre est total, E est dit bien
ordonné.
Exemple 4.4. L’ensemble (N, ≤) est bien ordonné. L’ensemble (Q+ , ≤) ne l’est pas car la suite n1 n∈N∗ est strictement
décroissante.
Exemple 4.5. Passons à quelques exemples d’ordres bien fondés sur N2 . Ils se généralisent à Np pour tout p ≥ 3 et
les deux premiers à des produits cartésiens d’ensembles bien fondés.
est bien fondé. En effet, partant d’un couple (a, b), il n’existe que b couples de la forme (a, x) et x < b, donc une
suite strictement décroissante de longueur au moins b + 2 atteint un couple (c, d) avec c < a. On conclut car N
est lui-même bien fondé. C’est un ordre total.
— L’ordre lexicographique gradué sur N2 , lui aussi total est défini par :
Proposition 4.6. Un ensemble ordonné E est bien ordonné si et seulement si toute partie non vide admet un plus
petit élément.
Démonstration. • Montrons le sens direct. On se donne une partie A non vide de E, et on veut montrer qu’elle
admet un plus petit élément. Soit x0 ∈ A. Si x0 n’est pas minimal, il existe x1 ≺ x0 . On réitère le procédé pour
construire une suite strictement décroissante xk ≺ xk−1 ≺ xk−2 ≺ · · · ≺ x0 : le procédé s’arrête car il n’existe
pas de suite infinie décroissante dans un ensemble bien ordonné : on a donc trouvé un élément minimal.
• Réciproquement, l’ordre sur E est nécessairement total (sinon il existerait une partie à deux éléments sans plus
petit élément). Si (xn )n∈N est une suite de l’ensemble, alors {xn | n ∈ N} possède un plus petit élément, donc la
suite n’est pas strictement décroissante et l’ensemble est bien ordonné.
Passons au principe d’induction, qui est à un ensemble bien fondé ce que la récurrence (forte) est à N.
Définition 4.7. On appelle prédicat sur un ensemble E une application de E dans l’ensemble des booléens {Vrai,Faux}.
Théorème 4.8. Soit (E, ) un ensemble bien fondé, notons M l’ensemble de ses éléments minimaux. Si l’application
P vérifie
• ∀x ∈ M, P(x),
• ∀x ∈ E\M, (∀y ≺ x, P(y)) ⇒ P(x)
Alors, pour tout x ∈ E, P(x).
Démonstration. Par l’absurde, supposons qu’il existe x0 ∈ E tel que P(x0 ) soit faux. Alors il existe un élément x1 ,
nécessairement pas dans M, tel que x1 ≺ x0 et tel que P(x1 ) soit faux. On recommence avec x1 , et on construit ainsi
une suite infinie décroissante, en contradiction avec le caractère bien fondé de E.
Théorème 4.9. Soit ϕ une application de l’ensemble des arguments A de la fonction f vers un ensemble bien fondé
E. On peut supposer ϕ surjective quite à considérer ϕ(E) à la place de E. Supposons que
— la fonction f termine pour tous les x dans l’ensemble MA = {x | ϕ(x) ∈ M} où M est l’ensemble des éléments
minimaux de E ;
— pour tout x dans A\MA , le calcul de f (x) ne requiert qu’un nombre fini (éventuellement aucun) d’appels à f ,
sur des arguments y vérifiant ϕ(y) ≺ ϕ(x), et la terminaison de ces appels entraîne celle de f (x).
Alors, la fonction f termine sur tout argument de A.
Démonstration. Il suffit d’appliquer le théorème précédent à la propriété sur E : P(z) : « les appels f (x) avec ϕ(x) = z
terminent. »
Définition 4.10. Les éléments x de A pour lesquels le calcul de f (x) ne nécessite aucun appel à f sont appelés cas
de base ou cas terminaux (les éléments de MA sont terminaux, mais ce ne sont pas forcément les seuls).
Remarque 4.11. On aura souvent des arguments dans un ensemble bien fondé, et la fonction ϕ sera l’identité où
la projection sur une composantes. Pour des structures plus complexes, l’application ϕ est souvent un indicateur à
valeurs dans N comme la taille d’une liste ou d’un tableau...
Exemple 4.12. La fonction fact ci-dessus termine.
Exemple 4.13. La fonction ci-dessous donnant la longueur d’une liste termine.
let rec longueur l=match l with
| [] -> 0
| _::q -> 1+longueur q
;;
On prend en effet comme application ϕ celle qui à une liste associe sa longueur.
Prenons quelques exemples légèrement plus complexe :
Exemple 4.14. Le calcul des coefficients binomiaux par la fonction suivante termine :
let rec binome n p=match (n,p) with
| _,0 -> 1
| 0,_ -> 0
| _ -> (n*binome (n-1) (p-1))/p
En effet, on peut prendre pour ensemble E l’ensemble N2 muni d’un des ordres vus plus haut : ils conviennent tous.
On peut aussi prendre pour E l’ensemble N avec la fonction ϕ : N2 → N qui a un couple associe la somme des éléments
du couple. Les mêmes ordres fonctionne pour la version suivante, bien moins efficace :
let rec binome n p=match (n,p) with
| _,0 -> 1
| 0,_ -> 0
| _ -> binome (n-1) p + binome (n-1) (p-1)
4.3 Correction
Montrer la correction d’une fonction signifie montrer qu’elle calcule ce qu’elle est sensée calculer. Tout le préambule
mathématique introduit permet de répondre facilement à cette question, dans le même style que la terminaison, avec
la propriété suivante. On reprend la fonction ϕ du théorème sur la terminaison.
Théorème 4.17. Considérons sur l’ensemble ϕ(E) le prédicat suivant : P(z) : « les f (x) pour ϕ(x) = z ont la bonne
valeur ». Supposons que
• ∀x ∈ MA , P(x).
• pour tout x dans A\MA , le calcul de f (x) ne requiert qu’un nombre fini d’appels d’arguments (yi )1≤i≤N qui
vérifient ϕ(yi ) ≺ ϕ(x) et
(∀1 ≤ i ≤ N, P(ϕ(yi ))) =⇒ P(ϕ(x))
Alors pour tout x ∈ A, P(x).
Démonstration. Immédiate.
En général, prouver la correction d’une fonction récursive sera relativement immédiat. Par exemple, il est facile de
voir que la syracuse renvoie 1 pour tout entier naturel si jamais elle termine. Prenons un autre exemple, le tri par
sélection sur les listes. On commence par écrire une fonction prenant en entrée une liste non vide q, et renvoyant un
couple formé du plus petit élément de q et de la liste de ses autres éléments, dans un ordre arbitraire.
let mini q=
let rec aux m reste p=match p with
| [] -> m, reste
| x::r when m<=x -> aux m (x::reste) r
| x::r -> aux x (m::reste) r
in aux ([Link] q) [] ([Link] q)
;;
La terminaison de la fonction aux se montre en considérant pour ϕ la taille de la liste p, la correction de aux se fait
en considérant la propriété suivante sur N : P(z) : « si p est une liste de taille z, et si m est plus petit que les éléments
de reste, alors aux m reste p renvoie le couple constitué du minimum parmi les éléments de m::p@reste, et d’une
liste formé des mêmes éléments, moins m. » L’appel de mini à aux montre qu’elle a bien l’effet escompté. Passons au
tri par sélection :
On prend ici pour fonction ϕ la taille de la liste q, et pour propriété la correction du tri sur les listes d’une certaine
taille.
Souvent, le O pourra être précisé en un Θ (c’est le cas pour toutes ces fonctions), et le résultat aussi : les propositions
et théorèmes qui suivent sont valables en remplaçant O par Θ. Dans la suite, on introduit les outils permettant de
résoudre de telles récurrences.
Avertissement. Dans les récurrences précédentes, il est crucial que les constantes multiplicatives cachées dans le O
ne dépendent pas de n ! C’est le cas dans les algorithmes pris en exemple, et ce sera toujours le cas lorsqu’on étudiera
la complexité en informatique en général, si bien qu’on ne le précise pas.
Pn Pn
Proposition 4.20 (Sommations classiques). Soient α > 0, q > 1. Alors k=0 k α = Θ(nα+1 ), k=0 q k = Θ(q n ).
Pn k q n+1 −1
Démonstration. • k=0 q = q−1 = Θ(q n ).
• Pour l’autre relation, la fonction t 7→ tα est une fonction croissante, ainsi
Z n n Z n+1
nα+1 X (n + 1)α+1 − 1
= tα dt ≤ kα ≤ tα dt =
α+1 0 1 α+1
k=1
n
Les deux termes à gauche et à droite sont des Θ(nα+1 ), donc k=0 k α aussi.
P
Exemple P 4.21. La solution de la récurrence C(n) = C(n − 1) + O(n) est C(n) = O(n2 ). En effet on a alors
n
C(n) = O ( k=0 k) = O(n2 ).
Théorème 4.22. Soit (un )n≥0 vérifiant pour n > 0 la relation un = aun−1 + bn , avec (bn ) une suite strictement
positive, a > 0, u0 ≥ 0. Si bn = O(bn ), on a suivant les cas :
— si b < a, alors un = O(an ) ;
— si b = a, alors un = O(nan ) ;
— si b > a, alors un = O(bn ).
(Le résultat est vrai en remplaçant les O par des Θ).
un bn
Démonstration. Posons vn = an . Ainsi vn = vn−1 + an pour tout n ≥ 0. On a donc pour n ≥ 0 :
n−1 n−1
X X bk
v n = v0 + (vk − vk−1 ) = v0 +
ak
k=1 k=1
Pn−1 Pn−1 k
bk b
et donc un = an v0 + k=1 ak = O an v0 + k=1 ak avec bk = O(bk ). Or la sommation classique des suites
géométriques donne :
n−1
X b
k O(1)
si b < a
= O(n)
si b = a
a O b n si b > a
k=1 a
un = aub n c + bud n e + bn
2 2
un = aub n c + bud n e + bn
2 2
avec a, b ≥ 0, entiers non tous deux nuls, bn > 0. Si b0n = O(bn ), alors la suite définie par u0n = au0 n + bu0 n + b0n
b2c d2e
et u01 = u1 vérifie u0n = O(un ).
Démonstration. Si b0n ≤ Cbn , avec C > 1, alors une récurrence immédiate montre que u0n ≤ Cun .
Remarque 4.26. En supposant b0n > 0, on obtient un résultat valable en remplaçant les O par des Θ, par symétrie.
Proposition 4.27. On reprend les mêmes notations, et on suppose (bn ) croissante. Alors (un ) est croissante.
Démonstration. Elle se fait par récurrence :
— u2 ≥ u1 , car (a + b) ≥ 1 ;
— pour n > 2, on a
un = aub n c + bud n e + bn ≥ aub n−1 c + bud n−1 e + bn−1 = un−1
2 2 2 2
un = aub n c + bud n e + bn
2 2
On suppose a, b entiers positifs non tous deux nuls, u1 ≥ 0. On pose α = log2 (a + b). Alors, si (bn )n≥1 est une suite
strictement positive vérifiant bn = O(nβ ), on a disjonction suivant les valeurs de β.
— β < α, un = O(nα ).
— β = α, un = O(nα log(n)).
— β > α, un = O(nβ ).
Démonstration. On se ramène donc à la même récurrence, mais avec bn = nβ à la place de O(nβ ), ce qui est légitime
d’après la proposition 4.25. Remarquons que la récurrence est plus simple si n = 2p est une puissance de 2 : u2p =
(a+b)u2p−1 +b2p . Étudions d’abord la suite vp = (a+b)vp−1 +2βp . D’après le théorème 4.22, on a donc le comportement :
— si 2β < (a + b), alors vp = O((a + b)p )
— si 2β = (a + b), alors vp = O((a + b)p p)
— si 2β > (a + b), alors vp = O(2pβ )
Or p = log2 (n) donc 2pβ = nβ et (a + b)p = 2p log2 (a+b) = 2log2 (n) log2 (a+b) = nlog2 (a+b) = nα . La conclusion est donc
exacte si p est une puissance de 2. Ensuite, il suffit de voir que (nα )n est croissante, donc d’après la proposition 4.27,
(un )n l’est aussi. On peut alors encadrer n par deux puissances de deux successives (ce qui fait sortir un facteur
constant) pour aboutir à la même conclusion, pour n quelconque.
Remarque 4.29. Expliquons le résultat obtenu en traçant l’arbre d’appels récursifs effectués. En figure 4.1 est repré-
senté celui du tri fusion, satisfaisant la relation C(n) = C(bn/2c) + C(dn/2e) + O(n). On notera qu’ici a + b = 2 = 2β :
on se trouve dans le cas 2β = a + b. Dans ce cas, l’arbre a une hauteur de log2 (n), et chaque niveau contribue à la
complexité totale de manière équilibrée, pour un coût O(n). Discutons des trois cas du théorème :
— si 2β < (a + b), alors c’est le bas de l’arbre qui contribue le plus à la complexité : le nombre d’appels récursifs
effectué fait que le coût hors appels récursifs est petit.
— si 2β > (a + b) à l’inverse, c’est le haut de l’arbre qui contribue le plus à la complexité : le coût hors appels
récursifs est élevé et l’emporte sur ceux-ci.
— si 2β = a + b comme ici, les O(log n) niveaux de l’arbres contribuent de manière équilibrée, chacun pour un coût
O(nα ), d’où la complexité O(nα log n).
n −→ O(n)
+
n n
2 + 2 −→ O(n)
+
n n n n
hauteur log2 (n) 22 + 22 + 22 + 22 −→ O(n)
+
.. .. .. .. .. .. .. .. ··· ..
. . . . . . . . .
+
n n n n
2k + 2k + ··· + 2k + 2k → 2k · O(1) = O(n)
Figure 4.1 – Complexité dans le tri fusion sur une liste de taille n = 2k .
Chapitre 5
5.1 Introduction
Le chapitre précédent nous a donné des outils pour montrer qu’une fonction récursive termine, est correcte, et
estimer sa complexité. Dans le chapitre 3, nous avons notamment vu le tri fusion, qui est un bel exemple d’algorithme
« diviser pour régner ». Le présent chapitre montre d’autres exemples de résolution de problèmes via des algorithmes
de ce type, dont on rappelle le fonctionnement :
— découper l’instance à résoudre en instances plus petites ;
— résoudre récursivement le problème sur les petites instances ;
— reconstituer une solution du problème sur l’instance initiale à partir des solutions précédemment obtenues.
Correction. Les propriétés « la fonction fission q renvoie un couple de listes (q_1,q_2) de tailles égales à 1 près,
dont les éléments sont ceux de q » et « la fonction fusion p1 p2 renvoie une liste triée dont les éléments sont ceux
de p1 et p2 si ces listes sont triées » se montre de manière immédiate par récurrence sur les quantités évoquées au
paragraphe précédent.
Complexité. En notant C(n) la complexité de fission, l’équation C(n) = C(n − 2) + O(1) a pour solution
C(n) = O(n). De même, avec n = n1 + n2 , la complexité de la fonction fusion est solution de C(n) = C(n − 1) + O(1),
d’où également une complexité O(n) pour cette fonction.
Complexité. L’équation de la complexité de la fonction tri_fusion sur une liste de taille n > 1 est donc C(n) =
C(bn/2c) + C(dn/2e) + O(n), dont le théorème du chapitre précédent montre que la solution est C(n) = O(n log n).
Refaisons brièvement la démonstration dans ce cas précis, lorsque n est une puissance de 2. n s’écrit alors 2k , avec
k ≥ 1. On a donc pour tout i ≥ 1, C(2i ) = 2C(2i−1 ) + O(2i ). En divisant par 2i , on obtient
C(2i ) C(2i−1 )
= + O(1)
2i 2i−1
k
C(2k ) X C(2i ) C(2i−1 )
Ainsi, = − +C(1) = O(k)
2k i=1 |
2i 2i−1
{z }
O(1)
k k
D’où C(n) = C(2 ) = O(k2 ) = O(n log n). Cette relation s’étend à n quelconque, comme montré dans le chapitre
précédent 1 .
Algorithme de Karatsuba.
Si n = 1 (les
n deux polynômes sont des constantes), le produit est simplement le produit des deux constantes. Sinon,
Pm−1 Pn−1
posons m = 2 , et découpons nos polynômes en 2. On écrit donc P0 = k=0 pk X k et P1 = k=m pk X k−m de sorte
que P = P0 +X m P1 , et de même Q = Q0 +X m Q1 . Posons ensuite T0 = P0 Q0 , T1 = P1 Q1 et T2 = (P0 +P1 )×(Q0 +Q1 ).
Comme P × Q = (P0 + X m P1 )(Q0 + X m Q1 ) = P0 Q0 + X m (P1 Q0 + P0 Q0 ) + X 2m P1 Q1 , on obtient le produit en
combinant les facteurs (Ti ) de la façon suivante : P × Q = T0 + X m (T2 − T1 − T0 ) + X 2m T1 . Les produits (Ti ) sont
eux-même calculés récursivement en exploitant cette idée. L’algorithme, en pseudo-code, est le suivant :
Supposons pour simplifier que n est une puissance de 2, donc s’écrit 2k . Dans chaque appel récursif, les instances
ont des tailles divisées exactement par 2. Pour déterminer la complexité globale, il est essentiel d’estimer la complexité
des deux étapes diviser et régner. Ici, on suppose qu’on ne compte que les opérations arithmétiques dans l’anneau A
(additions, multiplications, soustractions).
— Pour diviser, il faut créer les tableaux associés aux polyômes P0 et P1 , et calculer les polynômes P0 + P1 et
Q0 + Q1 . Ceci se fait en temps linéaire en n.
— Pour régner, il faut créer un tableau T de taille (2n − 1), et combiner les trois tableaux associées à T0 , T1 et T2
pour obtenir le tableau T . Ceci se fait également en temps linéaire en n puisqu’il suffit de parcourir les tableaux
T0 , T1 et T2 et de modifier un ou deux éléments de T pour chacun des éléments des trois tableaux.
Pour le calcul du produit, on fait trois appels récursifs pour résoudre des problèmes de taille divisée par 2. Ainsi, la
complexité C(n) de l’algorithme de Karatsuba satisfait à l’équation : C(n) = 3 × C(n/2) + O(n) dont la solution est
C(n) = O nlog2 (3) . Là encore, les résultats du chapitre précédent permettent d’étendre le résultat à n quelconque.
Code Caml
Voici le code. Il faut faire attention, la taille de p1 est la même que celle de p0 si n est pair, un de plus si n est
impair. De même pour q0 et q1. Ainsi t1 et t2 ont la même taille, supérieure à celle de t0 si n est impair.
En pratique ?
Le tableau suivant montre les temps en secondes. nécessaires en Python pour calculer sur mon ordinateur personnel
(Pocket PC de 2012) le produit de deux polynômes P et Q avec de petits coefficients entiers (tirés aléatoirement dans
l’intervalle [[−1000, 1000]]), de degrés variables, avec un algorithme naïf et avec l’algorithme de Karatsuba.
Pour mieux apercevoir les variations, on peut tracer le diagramme log-log de ces temps (voir figure 5.1). Une
complexité C(n) = nα donne une droite car log(C(n)) = α · log(n), ce qu’on observe sur le graphe. Une régression
linéaire fait apparaître des coefficients directeurs proches des valeurs théoriques 2 et 1.58 ' log2 (3).
Pour de petits polynômes, l’algorithme de Karatsuba est sans intérêt, mais il devient assez vite intéressant : il est
plus efficace que l’algorithme naïf pour des polynômes de degré au moins 500.
103
temps en secondes
102
101
100
10−1
103 104
n (degré des polynômes)
Figure 5.1 – Comparaison des temps (algorithme de Karatsuba, multiplication naïve), échelle log-log
Algorithme naïf
En notant A = (ai,j )0≤i,j<n , B = (bi,j )0≤i,j<n et C = (ci,j )0≤i,j<n , la formule du produit vue en cours de
Pn−1
mathématiques ci,j = k=0 ai,k bk,j mène à un algorithme de complexité O(n3 ).
Formules de Strassen
n n
Supposons que n soit pair, alors A, B et C se décomposent chacune en 4 blocs de taille 2 × 2 :
A1,1 A1,2 B1,1 B1,2 C1,1 C1,2
A= , B= et C=
A2,1 A2,2 B2,1 B2,2 C2,1 C2,2
On considère alors les 7 produits suivants :
On vérifie alors que les Ci,j s’obtiennent à partir des Pk comme suit :
C1,1 = P1 + P4 − P5 + P7
C1,2
= P3 + P5
C2,1 = P2 + P4
C
2,2 = P1 − P2 + P3 + P6
Notons que le simple produit par blocs effectue 8 multiplications : c’est là que se situe le gain en complexité de
l’algorithme de Strassen.
L’algorithme de Strassen utilise simplement les formules récursivement, dans le cas n = 1 on effectue simplement
le produit des deux coefficients. Pour n pair, on obtient donc l’équation de complexité C(n) = 7C(n/2) + O(n2 ) : en
effet, les additions et soustractions de matrices nécessaires avant le calcul des Pi ont un coût O(n2 ), de même que le
calcul des Ci,j à partir des Pk .
La solution de cette récurrence est C(n) = O(nlog2 (7) ), et log2 (7) ' 2.81 < 3. Pour n qui n’est pas une puissance
de 2, on peut simplement compléter les matrices avec des zéros pour obtenir une matrice d’une taille une puissance
de 2, ce qui ne change pas la complexité asymptotique 7 .
Implémentation pratique ?
La constante cachée dans le O(nlog2 (7) ) étant assez élevée, il faut implémenter finement l’algorithme pour obtenir
un gain par rapport à l’algorithme naïf, et ce gain ne s’observe que pour des matrices de tailles déja conséquentes. En
pratique, on n’utilise pas les formules de Strassen pour de petites matrices : on se contente de l’algorithme naïf.
5.4 Calcul de la paire de points les plus proches dans un nuage de points
Pour clore ce chapitre, on conclut par un problème géométrique : la recherche du couple de points les plus proches
dans un nuage de points. Le problème est le suivant : on se donne un tableau de taille n contenant des couples de
flottants, représentant un nuage de points, et on veut identifier les deux points les plus proches, naturellement avec la
meilleure complexité possible.
On sait que l’on peut trier les points d’un nuage réel de taille n en temps O(n log n) avec le tri fusion. Trouver le
couple de points les plus proches est alors très facile car ils seront placés contigüment dans le tableau : un parcours en
O(n) suffit. On en déduit donc que le problème à une dimension possède une solution en O(n log n). De manière assez
spectaculaire, on peut obtenir la même complexité pour le problème en dimension 2 qu’en dimension 1, ce qu’on va
établir.
On va appliquer une stratégie « diviser pour régner ». Si le nuage a peu de points (disons moins que quatre), on
applique l’algorithme naïf. Sinon, on peut séparer le nuage de points en deux parties (presque) égales, autour d’un axe
vertical. Remarquons tout de suite qu’il est commode que les points soient triés par abscisse croissante.
On calcule ensuite (récursivement) la distance minimale et le couple de points correspondant dans les parties
gauches et droites. Il faut ensuite calculer la distance entre les points situés de part et d’autre de la droite verticale.
Si on calcule naïvement cette distance, on a environ n/2 points à gauche et n/2 à droite, on obtient donc une
complexité O(n2 ), ce qui n’est pas avantageux. Toutefois, on peut optimiser cette partie :
7. En pratique, on se contenterait de rajouter une ligne et une colonne de zéros dans le cas n impair.
Encore faut-il avoir la structure de donnée adéquate, ce dont on discute dans la sous-section suivante.
8. Un examen attentif de la complexité montre que l’on obtiendrait un algorithme qui n’est pas en O(n log n).
9. On peut supposer les tableaux triés pour les ordres lexicographiques, le premier en regardant d’abord la composante x, et le second
la composante y. Ceci permet de gérer aussi les cas « pathologiques », par exemple si tous les points se retrouvent sur une même droite
x = c. On ne rentre pas dans les détails d’implémentation ici.
Chapitre 6
6.1 Introduction
La programmation dynamique 1 est une technique pour résoudre des problèmes d’optimisation : sur un univers
U, on cherche à minimiser (ou maximiser, la situation est symétrique) une certaine fonction, souvent à valeurs dans
les entiers. Concrètement, on se donne f : U → Z, et on cherche à déterminer x tel que f (x) = miny∈U {f (y)}
ou f (x) = maxy∈U {f (y)}, si cette quantité existe bien. Citons quelques exemples concrets :
— Dans le graphe suivant, quel est le poids d’un plus court chemin 2 entre le sommet 3 et le sommet 2 ? Cette
question est au programme de deuxième année.
4
5
0 1
6 2
4 8 7
3
7
3 2
12
— Dans la matrice suivante, on s’intéresse au chemin de la case en haut à gauche à celle en bas à droite, utilisant
seulement les déplacements → et ↓, qui maximise la somme des entiers rencontrés sur le chemin. Quel est ce
chemin et son poids ?
2 39 12 49 47 18 22 19
37 21 34 26 10 2 35 39
A= 31 21 12 26 34 27 7 22
20 46 16 2 11 40 36 13
18 30 32 37 28 24 9 6
On verra que la programmation dynamique est une technique qui peut s’appliquer pour résoudre algorithmiquement
ces problèmes de manière efficace. Néanmoins, elle ne s’applique pas à tous les problèmes d’optimisation, et on verra
que lorsqu’elle s’applique elle n’est pas forcément la technique la plus efficace.
Le nombre de chemins possibles est donc exponentiel en n, déja pour n = 30 un algorithme qui explore tous les chemins
possibles est impraticable.
En effet :
• la relation pour les trois premiers points est évidente, car dans ce cas il n’y a qu’un seul chemin licite de la case
(0, 0) à la case (i, j). On suppose dorénavant i > 0 et j > 0 ;
• À partir d’un chemin C menant à la case (i − 1, j), on en construit un menant à la case (i, j) via un déplacement
↓. En choisissant C de poids maximal pi−1,j , on obtient pi,j ≥ ai,j + pi−1,j . Comme on obtient de manière
symétrique pi,j ≥ ai,j + pi,j−1 , on conclut que pi,j ≥ ai,j + max{pi−1,j , pi,j−1 } ;
• Réciproquement, tout chemin licite menant à la case (i, j) passe par (i − 1, j) ou (i, j − 1). Prenons-en un de
poids maximal pi,j , et supprimons le dernier mouvement, on obtient un chemin de poids pi,j − ai,j menant à la
case (i − 1, j) ou à la case (i, j − 1). Ainsi max{pi−1,j , pi,j−1 } ≥ pi,j − ai,j .
Et la relation est démontrée. Cette relation fournit un algorithme récursif pour le calcul de pn−1,m−1 . Néanmoins cet
algorithme revient à explorer tous les chemins possibles et a la même complexité que la recherche exhaustive.
Remarquez que les deux dernières boucles while servent simplement à remonter d’un des bords (gauche ou supérieur)
à la case initiale, et seule l’une des deux est utile. Appliquons l’algorithme à la matrice A de l’exemple :
# max_chemin a ;;
- : string list = [">"; ">"; ">"; ">"; "v"; "v"; ">"; "v"; ">"; ">"; "v"]
Le lecteur non convaincu vérifiera que ce chemin est de poids 315, ce qui correspond à p4,7 = pn−1,m−1 .
3. Pour pallier le problème évoqué au point (2), on calcule alors les Mfi ,Ui utiles (y compris Mf,U ) itérativement,
en faisant usage d’un tableau pour stocker tous ces éléments. On peut parfois se contenter de n’en stocker que
certains, par exemple dans le problème précédent un espace O(n) en plus de la matrice A (au lieu de O(nm))
suffirait 3 pour calculer pn−1,m−1 .
4. Enfin, on modifie légèrement le calcul des Mfi ,Ui pour obtenir en même temps 4 pour tout i un xi satisfaisant
fi (xi ) = Mfi ,Ui . En général cette étape n’est pas difficile.
1. On partitionne l’ensemble U en sous-ensembles disjoints (Uei )i , les (Ui ) étant des ensembles associés à des « sous-
problèmes combinatoires » et les (Uei ) des ensembles en bijection avec les (Ui ) : une légère modification d’un
élément de Ui donne un élément de Uei .
2. Écrire que UP= ∪i Uei (l’union étant disjointe) fournit une relation de récurrence permettant de calculer |U|, à
savoir |U| = i |Ui |.
3. On calcule plutôt |U| itérativement, en faisant usage d’un tableau.
Pour résumer, la résolution d’un problème de combinatoire suit essentiellement les points (1) à (3) évoqués pour la
résolution d’un problème d’optimisation par programmation dynamique, le point (4) n’ayant pas de sens ici. Détaillons
rapidement deux exemples.
On cherche le nombre de chemins sur partant du coin en haut à gauche jusqu’au coin en bas à droite, en suivant
seulement les directions 5 → et ↓. Résolvons le rapidement :
1. On peut indexer les points de la grille de (0, 0) (en haut à gauche) à (n, m) (en bas à droite). Notons Ci,j
l’ensemble des chemins de (0, 0) à (i, j), utilisant seulement les déplacements autorisés. Alors pour tout i, j ≥ 0,
on a
Ci,j = C^
i−1,j ∪ Ci,j−1
^
où C^i−1,j est l’ensemble des chemins de Ci−1,j , complétés par le segment (i − 1, j) → (i, j), et de même pour
Ci,j−1 . On convient que C−1,j = Ci,−1 = ∅ et que C0,0 contient comme unique élément le chemin réduit au point
^
(0, 0).
2. En notant Ni,j = |Ci,j |, on a donc la relation de récurrence Ni,j = Ni−1,j + Ni,j−1 , valable pour i, j > 0, sinon
Ni,0 = N0,j = 1.
3. On peut donc tabuler les Ni,j dans un tableau de taille (n + 1) × (m + 1), car c’est Nn,m qui nous intéresse.
Le lecteur pourra vérifier qu’il y a 1287 chemins convenables, pour l’exemple 6 de la grille de taille 5 × 8.
Un problème de pavage
On considère un rectangle de taille 2 × n, et on s’intéresse aux pavages de ce rectangle par des dominos 1 × 2. La
figure suivante montre deux exemples de pavage d’un rectangle 2 × 7.
Notons Fn le nombre de pavages possibles d’un rectangle de taille 2 × n. Cherchons une relation de récurrence nous
permettant de calculer Fn efficacement. Supposons n ≥ 2 et considèrons le domino occupant le coin en haut à droite
du rectangle.
— si ce domino est placé verticalement (comme dans le pavage de gauche dans la figure ci-dessus), alors il reste à
paver un rectangle 2 × (n − 1) : le nombre de tels pavages est donc Fn−1 ;
— sinon, le domino est placé horizontalaement (comme dans le pavage de droite). Nécessairement, un autre domino
horizonal est placé en dessous, il reste donc à paver un rectangle de taille 2 × (n − 2), et il y a Fn−2 tels pavages.
Ainsi, (Fn )n satisfait la relation de récurrence Fn = Fn−1 + Fn−2 pour n ≥ 2 (on reconnaît la suite de Fibonacci...),
avec conditions initiales F0 = F1 = 1. Comme on l’a vu au chapitre 3, il vaut mieux faire usage d’un tableau que de
récursivité pour calculer efficacement Fn .
5. Oui, ce problème de combinatoire ressemble fortement au problème d’optimisation vu précédemment : c’est fait exprès !
6. Comme déja évoqué, il y a en fait n+m chemins possibles, et 1287 = 13
n 5
...
Remarque 6.1. Ce problème était facile. Le lecteur pourra chercher le nombre de pavages possibles d’un rectangle
3 × 30 avec des dominos 1 × 2, c’est moins évident !
Avant de voir d’autres exemples de résolution de problèmes d’optimisation à l’aide de la programmation dynamique,
parlons brièvement des méthodes gloutonnes.
On a vu que dans le problème du chemin de poids maximal dans une matrice, l’algorithme glouton ne donnait pas
une réponse optimale. Néanmoins, c’est le cas pour certains problèmes : il faut alors prouver que le choix localement
optimal se révèle être un choix globalement optimal. On verra notamment en deuxième année un algorithme de calcul de
plus courts chemins depuis une origine fixée dans un graphe pondéré à poids positifs, qui se révèle être un algorithme 7
glouton. L’intérêt d’un algorithme glouton par rapport à un algorithme faisant usage de programmation dynamique
est sa complexité, en général bien moins élevée. Par exemple pour le problème précédent, l’algorithme glouton n’a
qu’une complexité O(n + m).
est « rhmie », de longueur 5. Ce problème a des applications pratiques, notamment en génétique : la proximité de
deux individus peut être évaluée en calculant une sous-séquence commune 8 à deux séquences d’ADN prises sur les
individus.
Une relation de récurrence. La discussion précédente nous fournit une relation de récurrence sur les `i,j , à savoir :
0 si i = 0 ou j = 0 ;
`i,j = 1 + `i−1,j−1 si les préfixes de tailles i et j de s et t terminent par la même lettre;
max{`i−1,j , `i,j−1 } sinon.
Calcul itératif des `i,j et construction d’une sous-séquence commune. Pour calculer les (`i,j )0≤i≤n,0≤j≤m ,
on procède itérativement en remplissant un tableau de taille (n + 1) × (m + 1). De manière similaire au problème du
chemin de poids maximal, il suffit de remonter depuis la case (n, m) jusqu’à la case (0, 0) (ou plus simplement à un
bord supérieur ou inférieur du tableau) pour construire une sous-séquence commune. Voici une implémentation :
let plssc s t=
let n, m=[Link] s, [Link] t in
let long=Array.make_matrix (n+1) (m+1) 0 in
for i=1 to n do
for j=1 to m do
if s.[i-1]=t.[j-1] then
long.(i).(j) <- 1+long.(i-1).(j-1)
else
long.(i).(j) <- max long.(i-1).(j) long.(i).(j-1)
done
done ;
let x=[Link] long.(n).(m) 'a' and i=ref n and j=ref m and k=ref (long.(n).(m)-1) in
while !k>=0 do
if long.( !i).( !j) = long.( !i-1).( !j) then
decr i
else if long.( !i).( !j) = long.( !i).( !j -1) then
decr j
else begin
x.[ !k] <- s.[ !i-1] ;
decr i ;
decr j ;
decr k ;
end
done ;
x
;;
Une fois les `i,j calculés, on connaît la longueur d’une plus longue sous-séquence commune. On crée alors une chaîne
à la bonne taille, qu’on va modifier 9 . Pour cela, on remonte depuis la case (n, m). Si `i,j = `i−1,j ou `i,j−1 , on peut
8. La distance d’édition entre deux séquences est également intéressante, et se calcule également par programmation dynamique !
9. On rappelle que les chaînes de caractères en Caml sont très semblables à des tableaux de caractères. On accède ou modifie le k-ème
caractère de x via x.[k], et [Link] permet, de manière analogue à [Link], de créer une chaîne de caractères de la longueur
désirée. Attention, depuis récemment les chaînes de caractères tendent à devenir immuables en Ocaml (le code précédent fonctionne mais
avec un avertissement), et un type spécial (Bytes, c’est-à-dire octets) remplace les chaînes mutables. Avec cette version, il faut créer un
objet de type Bytes, que l’on convertit à la fin du code en String.
remonter d’un cran vers le haut ou vers la gauche. Sinon, on a trouvé un nouveau caractère, et on remonte en diagonale
à la case (i − 1, j − 1). Testons :
Complexité. La détermination des `i,j se fait en temps O(nm), alors que la construction d’une plus longue sous-
séquence commune ne prend qu’un temps O(n + m).
Sous-structure optimale. Si on sait qu’un carré de zéros de taille p > 0 a son coin en bas à droite à l’indice (i, j),
cela signifie que les carrés de taille p − 1 dont le coin en bas à droite est parmi {(i − 1, j), (i, j − 1), (i − 1, j − 1)} sont
tous remplis de zéros, comme le montre la figure ci-dessous.
Relation de récurrence. La découverte de la sous-structure optimale nous permet d’exhiber facilement la relation
de récurrence suivante, sur la taille du plus grand carré de zéro terminant à l’indice (i, j), qu’on note ti,j , en convenant
que t−1,j = ti,−1 = 0 :
0 si ai,j = 1 ;
ti,j =
1 + min{ti−1,j , ti,j−1 , ti−1,j−1 } sinon.
On laisse au lecteur le soin d’implémenter un code permettant de calculer la taille d’un plus grand carré de zéro dans
une matrice binaire, et de donner la position de son coin en bas à droite.
Chapitre 7
Définition 7.2 (feuilles et nœuds internes). Les éléments de A sont appelés les nœuds de l’arbre. Pour x un nœud,
on appelle arité de x le nombre de fils de x. Un nœud d’arité 0 est appelé une feuille, sinon c’est un nœud interne.
Par exemple en figure 7.1, n est un nœud interne, f est une feuille. On étudiera plus particulièrement les arbres
évoqués dans la définition suivante.
Définition 7.3 (arbre binaire). On appelle arbre binaire un arbre dont les nœuds sont d’arité au plus deux, et arbre
binaire entier un arbre binaire dont les nœuds sont tous d’arité zéro ou deux.
Définition 7.4 (Profondeur et hauteur). Avec r la racine d’un arbre A et x un de ses nœuds, on a vu qu’il existait
un unqiue entier n ≥ 0 et d’uniques nœuds x1 , . . . , xn−1 tels que x ≺ x1 ≺ · · · ≺ xn−1 ≺ xn = r.
— On appelle profondeur de x l’entier n ≥ 0 ;
— On appelle hauteur d’un arbre la profondeur maximale de ses nœuds.
Exemple 7.5. La racine est l’unique nœud à profondeur 0. Les arbres des figures 7.1 et 7.2 ont tous hauteur 3.
1. En informatique, les arbres poussent de haut en bas.
Définition 7.6 (sous-arbre enraciné). Soit x un nœud d’un arbre A. On considère l’ensemble
Ax = {y ∈ A, ∃n ∈ N, ∃x1 , . . . , xn−1 ∈ A | y = x0 ≺ x1 ≺ · · · ≺ xn = x}
Alors on vérifie aisément que la restriction de ≺ à Ax munit Ax d’une structure d’arbre, de racine x. Cet arbre se
nomme le sous-arbre de A enraciné en x. On dit aussi que les éléments de Ax forment la descendance de x dans A.
Remarque 7.8. Si un arbre est d’arité maximale 1, il a exactement h + 1 nœuds, avec h sa hauteur.
Corollaire 7.9. La hauteur h d’un arbre à n nœuds tous d’arité au plus a > 1 vérifie
loga ((a − 1)n + 1) − 1 ≤ h ≤ n − 1
Appliquons ce résultat dans le cas a = 2 :
Corollaire 7.10. Soit A un arbre binaire à n nœuds. Sa hauteur h vérifie blog2 (n)c ≤ h ≤ n − 1
Démonstration. On prend donc a = 2 dans le corollaire précédent. Ainsi h + 1 ≥ log2 (n + 1) > log2 (n) ≥ blog2 (n)c.
Ainsi h + 1 est un entier strictement supérieur à l’entier blog2 (n)c, donc h ≥ blog2 (n)c.
12 5 8 7
17 1 14 4 3
0 6 4
#ex_arbre ;;
- : int arbre =
N (8,
[N (12, []);
N (5, [N (17, [N (0, []); N (6, [])]); N (1, [N (4, [])])]);
N (8, [N (14, []); N (4, []); N (3, [])]);
N (7, [])])
Pour parcourir un tel arbre, on utilise en général deux fonctions : l’une qui prend en entrée un arbre, et l’autre une
liste d’arbres. Elles vont s’appeler l’une l’autre et donc être mutuellement récursives. Voici un exemple de parcours,
pour calculer la hauteur d’un tel arbre :
Si on veut faire la distinction entre feuilles et nœuds internes (et leur donner des étiquettes de type différents), on
peut utiliser par exemple le type suivant.
type ('a, 'b) arbre = F of 'a | N of 'b * ('a, 'b) arbre list;;
Voici un exemple d’utilisation : une expression arithmétique se représente naturellement par un arbre binaire entier,
les étiquettes des nœuds internes étant les opérateurs et celles des feuilles étant les opérandes, voir figure 7.4.
+ −
3 4 5 ×
2 6
On a utilisé un type énuméré pour définir les opérateurs. L’évaluation d’une telle expression se fait récursivement, par
filtrage :
Où la fonction traduit renvoie la fonction int -> int -> int associée à l’opérateur op, qu’on laisse au lecteur le
soin d’écrire. Testons :
# evalue ;;
- : (int, op) arbre -> int = <fun>
# evalue expr ;;
- : int = -49
14
14
7 20
7 20
3 10 16 V
3 10 16
1 V V V V 18
1 18
V V V V
Figure 7.5 – Un arbre binaire à étiquettes entières, et l’arbre binaire entier associé.
Donnons pour terminer une fonction permettant de calculer la hauteur 2 d’un tel arbre :
Par exemple :
#hauteur ex_ab ;;
- : int = 3
# prefixe expr ;;
- : (int, op) etiq list =
[B Fois; B Plus; A 3; A 4; B Moins; A 5; B Fois; A 2; A 6]
# infixe expr ;;
- : (op, int) etiq list =
[A 3; B Plus; A 4; B Fois; A 5; B Moins; A 2; B Fois; A 6]
# postfixe expr ;;
- : (op, int) etiq list =
[A 3; A 4; B Plus; A 5; A 2; A 6; B Fois; B Moins; B Fois]
Il est notable de voir que l’énumération donnée par le parcours préfixe ou le parcours postfixe permet de reconstruire
l’arbre, contrairement à celle du parcours infixe.
Proposition 7.13. Si les étiquettes des nœuds internes et des feuilles ont des types différents, alors à une énumération
préfixe ou postfixe correspond un seul arbre binaire entier.
Voici une preuve dans le cas de l’énumération postfixe, on laisse au lecteur le soin de l’adapter à l’énumération
préfixe. Elle débute par un lemme.
Lemme 7.14. Considérons l’énumération postfixe d’un arbre binaire entier. On parcourt l’énumération avec un comp-
teur s initialisé à 0, on ajoute +1 pour une feuille, et −1 pour un nœud interne. Alors s est toujours strictement positif
après le début de l’énumération, et termine par 1.
Remarque 7.15. Cette propriété sur l’énumération postfixe a été utilisée dans certaines calculatrices 3 , et s’étend à
des expressions faisant usage d’opérateurs d’arité différente de 2. L’intérêt est que les parenthèses sont inutiles pour
donner l’expression arithmétique, ce qui fournit un gain de temps à l’utilisateur. Une pile suffit pour écrire une fonction
d’évaluation d’une expression donnée sous la forme du parcours postfixe, qu’on laisse au lecteur le soin d’implémenter :
Remarque 7.16. L’énumération infixe ne suffit pas pour reconstruire l’arbre, comme le montre l’exemple des deux
arbres ci-dessous, ayant même énumération :
3. À notation polonaise inversée, qui est un autre nom pour l’énumération postfixe de l’arbre associé à l’expression.
4. La priorité de × sur + et − permet de s’affranchir de certaines parenthèses... Mais ce n’est qu’une convention !
× −
+ − + ×
3 4 5 × 3 × 2 6
2 6 4 5
Figure 7.6 – Deux arbres d’énumération infixe 3 + 4 × 5 − 2 × 6 : les parenthèses sont obligatoires 4 pour donner un
sens à l’expression.
let largeur p=
let rec aux q=match q with
| [] -> []
| _ -> (racines q)@(aux (ss_arbres q))
in aux [p]
;;
La fonction aux de largeur renvoie l’énumération « en largeur » d’une liste d’arbres : si la liste est non vide, elle
énumère les racines, et se rappelle récursivement sur la liste des sous-arbres. largeur se contente d’un unique appel à
aux. Testons :
#largeur expr ;;
- : (int, op) etiq list =
[B Fois; B Plus; B Moins; A 3; A 4; A 5; B Fois; A 2; A 6]
Remarque 7.17. Les parcours en profondeur préfixe et postfixe, ainsi que le parcours en largeur, se généralisent
à d’autres arbres que les arbres binaires entiers, avec des fonctions assez semblables à celles vues dans ce chapitre.
Le parcours infixe se généralise à des arbres binaires (non nécessairement binaires entiers), mais pas à des arbres
quelconques.
Deuxième partie
Chapitre 8
Utilisation d’une liste non triée. Avec une liste non triée, quelques-unes des opérations sont faciles (notamment
l’insertion d’un nouvel élément), par contre l’opération consistant à retirer l’élément prioritaire n’est pas évidente car
il faut parcourir toute la liste pour calculer le maximum. Utiliser une liste non triée n’est donc pas une bonne idée
d’implémentation.
Utilisation d’une liste triée. Avec une liste triée (dans l’ordre décroissant), il est maintenant facile de retirer
l’élément de plus grande priorité d’une FP-max. Par contre, l’insertion d’un nouvel élément nécessite de chercher où
insérer le nouvel élément dans la liste, ce qui prend un temps linéaire en la taille de la liste dans le pire cas. Utiliser
une liste triée n’est donc pas non plus une bonne idée.
Définition 8.1. Un tas-max est un arbre binaire complet à gauche, tel que l’étiquette d’un nœud quelconque de l’arbre
soit supérieure ou égale à celles de ses fils.
18
15 16
14 8 3 5
11 9 4
La figure 8.1 présente un tel tas-max. Pour mémoire, un arbre est dit binaire lorsque tous ses nœuds ont au plus
deux fils. Il est dit complet si toutes ses feuilles se trouvent à la même profondeur (tous les niveaux de l’arbre sont
remplis). Un arbre binaire complet à gauche a ses feuilles sur les deux derniers niveaux, l’avant dernier étant rempli
au maximum et le dernier rempli le plus à gauche possible.
Avant de passer à l’implémentation concrète d’un tel arbre, donnons un encadrement de sa hauteur en fonction de
son nombre de nœuds.
Définition 8.3. La profondeur d’un nœud dans un arbre binaire est définie inductivement :
— la racine est à profondeur zéro ;
— la hauteur d’un nœud qui n’est pas la racine est celle de son parent, plus un.
Définition 8.4. La hauteur d’un arbre est la profondeur maximale de ses feuilles.
Proposition 8.5. Un arbre binaire complet à gauche de hauteur h a entre 2h et 2h+1 − 1 nœuds.
Démonstration. On numérote les niveaux de l’arbre par profondeur croissante. L’arbre possède h + 1 niveaux, les h
premiers étant remplis cela représente 1 + 2 + · · · + 2h−1 nœuds, soit 2h − 1. Le dernier niveau comporte entre 1 et 2h
nœuds, ce qui donne l’encadrement désiré.
Corollaire 8.6. La hauteur d’un arbre binaire complet à gauche à n nœuds est blog2 (n)c.
Démonstration. Immédiat.
Remarque 8.7. Ainsi, la hauteur d’un arbre binaire complet à gauche à n nœuds est en O(log n). Toutes les fonctions
que l’on écrira dans la suite auront cette complexité, ce qui mène à une implémentation de la structure de file de priorité
bien plus intéressante qu’avec des listes !
Augmentation de l’étiquette. Imaginons que l’on augmente l’étiquette d’un nœud. Alors la propriété de tas-max
n’est plus nécessairement vérifiée, mais le seul endroit où elle peut être violée est entre ce nœud et son parent : il se
peut que l’étiquette du parent soit maintenant strictement inférieure. Dans ce cas, on peut échanger l’étiquette du
nœud avec celle de son parent, et réitérer l’opération : l’étiquette augmentée va progressivement remonter jusqu’à ce
que la structure soit rétablie. Essentiellement, on applique l’algorithme récursif 8.8 pour rétablir la structure de tas
(E(s) désigne l’étiquette du nœud s) :
Analysons l’algorithme :
• la terminaison est immédiate, car si un appel récursif est effectué, c’est avec un nœud dont la profondeur est
strictement inférieure à celle de s ;
• la correction est immédiate, pour peu que l’on précise ce qu’est un « presque tas-max », dont un sommet s est
marqué : il s’agit d’un arbre vérifiant toutes les propriétés de tas-max, sauf peut-être E(parent(s)) ≥ E(s). Il
est clair que si l’algorithme s’arrête alors t est un tas-max, et si un appel récursif est effectué, c’est clairement
sur un presque tas-max et sur le sommet pouvant violer la condition de tas ;
• la complexité est clairement linéaire en la profondeur de s, majorée par la hauteur du tas.
18 18 18
15 16 15 16 17 16
14 8 3 5 17 8 3 5 15 8 3 5
11 17 4 11 14 4 11 14 4
Diminution de l’étiquette. À l’inverse, lorsqu’on diminue l’étiquette d’un nœud, la propriété de tas peut être
violée car l’étiquette du nœud est plus grande que l’un (au moins) de ses enfants. Dans ce cas, il suffit d’échanger le
nœud avec son fils ayant la plus grande étiquette pour que le problème descende d’un cran, voir l’algorithme 8.9.
De même, la terminaison et la correction sont faciles à montrer par récurrence décroissante sur la profondeur du
nœud s : si s est une feuille, il n’y a rien à faire, et sinon on reporte éventuellement le problème à un nœud de plus
grande profondeur. La complexité est clairement linéaire en la distance (maximale) entre s et une feuille, majorée par
la hauteur du tas.
7 16
15 16 15 7
14 8 3 5 14 8 3 5
11 9 4 11 9 4
Rajout d’un nœud. Il suffit de placer le nœud de façon à maintenir la structure d’arbre binaire complet à gauche,
et d’appeler monter_nœud pour rétablir la structure de tas.
Suppression de la racine. Pour rétablir la structure de tas-max facilement si l’on supprime la racine, il suffit d’y
placer le nœud situé en dernière feuille, puis d’appeler descendre_nœud.
18
15 16
18 15 16 14 8 3 5 11 9 4
14 8 3 5
11 9 4
On peut facilement stocker un arbre binaire complet à gauche dans un tableau (ce qui rendra facile les échanges
d’étiquettes) : il suffit de stocker les éléments de l’arbre dans l’ordre du parcours en largeur. On vérifie alors aisément
que, en identifiant nœud de l’arbre et indice dans le tableau :
Proposition 8.10. Si i est l’indice d’un élément du tableau, correspondant au i-ème nœud dans l’énumération du
parcours en largeur (initialisée à partir de la racine d’indice 0) :
— si i 6= 0 (donc i n’est pas la racine), le père de i est i−1
2 ;
— si i possède un fils gauche, c’est 2i + 1 ;
— de même, son fils droit éventuel est 2i + 2.
Démonstration. Il suffit de montrer la proposition sur les indices des fils, l’assertion sur l’indice du parent en résulte.
On vérifie (par récurrence immédiate) que les nœuds à profondeur h ont pour indices 2h − 1, 2h , · · · , 2h+1 − 2. Ainsi le
nœud le plus à gauche à la profondeur h + 1 est 2h+1 − 1, le suivant 2h+1 , qui s’obtiennent bien comme 2(2h−1 − 1)
auquel on ajoute 1 ou 2. Il en va donc de même des suivants.
Le champ n désigne le nombre d’éléments effectivement présents dans la file de priorité, le tableau tab a alors ses n
premiers éléments qui forment le tas.
Couples (priorité, élément). En pratique pour implémenter une structure de file de priorité, il faut stocker dans
le tableau des couples (priorité, élément). La comparaison d’éléments en Caml se faisant avec l’ordre lexicographique,
toutes les fonctions que l’on va écrire compareront les couples comme il se doit.
Fonctions basiques. Les fonctions fg, fd, pere et echanger nous serviront à manipuler facilement le tableau associé
au tas. La fonction creer_fp prend en paramètre la taille du tableau (la capacité de la file), ainsi qu’un élément x
utile pour fixer le type de la file de priorité, même si la file est vide à la création. Enfin, pour tester si une file de
priorité est vide, il suffit de vérifier que le champ n est nul.
let fg i = 2*i+1 ;;
let fd i = 2*i+2 ;;
let pere i = (i-1)/2 ;;
let echanger t i j = let a=t.(i) in t.(i) <- t.(j) ; t.(j) <- a ;;
Monter et descendre. Pour implémenter les fonctions monter_noeud et descendre_noeud, il suffit de suivre les
algorithmes 8.8 et 8.9. Les deux fonctions prennent en paramètre le tableau sur lequel travailler et un indice i qui
est celui du nœud pouvant violer la structure de tas. La fonction descendre_noeud prend également en paramètre le
nombre d’éléments n du tas associé (seuls les n premiers éléments du tableau forment le tas).
1. Une implémentation plus générale, sans hypothèse sur la capacité de la file de priorité, consiste à utiliser un tableau redimensionnable
(similaire à une liste Python) : il n’est pas très compliqué d’implémenter une telle structure, il suffit de doubler la taille du tableau utilisé
lorsque l’on manque de place. Il faut alors recopier tous les éléments de l’ancien tableau vers le nouveau, ce qui est coûteux, mais cette
opération est faite suffisamment peu souvent pour que l’ajout d’un élément à la structure se fasse en complexité amortie constante.
Fonctions de file de priorité. On donne maintenant l’implémentation des deux principales opérations de file de
priorité max (ajouter un élément, supprimer le maximum), exceptées celles qui modifient une clé, dont on parlera un
peu plus loin.
Pour enfiler un élément, on suppose qu’il y a assez de place dans le tableau, et on rajoute un élément à la suite
des autres éléments du tas. On incrémente le nombre d’éléments contenus dans la file, et on appelle monter_noeud
sur le dernier élément du tas. À l’inverse, pour retirer le maximum (la racine du tas), on choisit de l’échanger avec le
dernier élément du tas, en décrémentant le nombre d’éléments contenus dans la file. On appelle descendre_noeud sur
la nouvelle racine, et on renvoie l’élément supprimé.
let enfiler_fp f x=
[Link].(f.n) <- x ;
f.n <- f.n + 1 ;
monter_noeud [Link] (f.n - 1)
;;
let supprimer_max_fp f=
f.n <- f.n - 1 ;
echanger [Link] 0 f.n ;
descendre_noeud [Link] f.n 0 ;
[Link].(f.n)
;;
8.2.6 Complexité
Ainsi implémentée, la structure de file de priorité possède une complexité O(log n) pour toutes ses opérations, où
n est le nombre d’éléments présents dans la file de priorité, exceptée la création qui est linéaire en la capacité choisie,
et le test d’égalité au vide qui s’exécute en temps constant.
La correction est relativement évidente. En terme de complexité, les opérations enfiler_fp et supprimer_max_fp
s’exécutent en temps O(log n) avec n la taille du tableau (qui est aussi la taille de la capacité de la file de priorité que
l’on crée) : on en déduit une complexité totale O(n log n).
Remarque 8.12. Il est en fait possible de trier le tableau « en place » avec ces idées : essentiellement on trans-
forme le tableau en tas à l’aide de monter_noeud, puis on le trie en réécrivant essentiellement le code de la fonction
supprimer_max_fp.
cheval chat
castor castor
Proposition 8.14. Un arbre binaire est un ABR si et seulement si l’énumération infixe de ses nœuds est strictement
croissante.
Remarque 8.15. L’énumération infixe n’est pas unique, elle correspond à plusieurs ABR. C’est le cas par exemple
pour les ABR de la figure 8.5 : les clés sont les mêmes donc les deux arbres ont même énumération infixe.
Définition 8.16. La hauteur d’un ABR est définie inductivement comme suit :
— la hauteur de l’arbre vide est -1 ;
— pour un arbre non vide, elle est égale au maximum des hauteurs des sous-arbres gauche et droit, plus un.
Remarque 8.17. On peut considérer qu’un ABR non vide est un arbre binaire entier, les « feuilles » de l’arbre étant
des arbres vides. C’est cohérent avec la définition de la hauteur, et pratique avec l’implémentation Caml que l’on va
voir au paragraphe suivant.
Proposition 8.18. Le nombre de nœuds n d’un ABR (non vide) de hauteur h est compris entre h + 1 et 2h+1 − 1
Démonstration. La borne 2h+1 − 1 est obtenue pour un arbre binaire complet, comme dans le cas des tas. Un chemin
de la racine a une feuille à profondeur h comportant h + 1 nœuds, l’autre borne est démontrée.
Corollaire 8.19. La hauteur h d’un ABR (non vide) à n nœuds vérifie blog2 (n)c ≤ h ≤ n − 1.
On a rendu le deuxième champ mutable, car dans un dictionnaire on peut vouloir changer la valeur associée à une
clé : dans ce cas il suffit de retrouver le nœud à partir de la clé et modifier la valeur associée. Ainsi la modification
d’une valeur est essentiellement une recherche de clé. Dans la suite on oubliera les valeurs pour ne considérer que les
clés, d’un point de vue algorithmique les opérations sont les mêmes.
Fonctions basiques. Créer un arbre binaire de recherche (vide) et tester si un arbre binaire de recherche est vide
sont évidentes :
Recherche et insertion dans un ABR. Pour rechercher un élément ou insérer un élément dans un ABR, on suit
le même principe de cheminement dans l’arbre ; la condition sur les clés impose que si l’on cherche x dans un arbre de
racine y, on est dans l’un des trois cas suivants :
— soit y = x, auquel cas on a trouvé x ;
— soit y < x, auquel cas x ne peut se trouver que dans le sous-arbre droit du nœud étiqueté par y ;
— soit y > x, et x ne peut se trouver que dans le sous-arbre gauche.
Pour l’insertion de x dans l’ABR, on chemine dans l’arbre jusqu’à arriver à une feuille Vide, que l’on remplace par
un arbre contenant simplement x. On choisit par convention de renvoyer l’arbre à l’identique si x est déja présent 4
Suppression d’un élément dans un ABR. La suppression d’un élément est légèrement plus complexe. Comme
pour l’insertion, on se ramène à la suppression de la racine d’un ABR. On propose alors deux solutions.
• Suppression par fusion : si x est la racine de l’ABR et l’élément à supprimer, on peut fusionner les deux sous-
arbres gauche et droit de l’ABR. Cette fusion est immédiate si l’un des deux arbres est vide, et sinon elle peut
se faire en suivant le schéma de la figure 8.6, qui donne bien un ABR si les deux arbres situés à gauche en sont
bien, et que les clés du premier sont strictement inférieures aux clés du second. Bien sûr le choix de x1 comme
nouvelle racine est arbitraire et on pourrait prendre symétriquement x2 .
x1 x2 x1
g1 g2 fusion g1 x2
d1 d2
fusion
d2
d1 , g2
Figure 8.6 – Fusion de deux ABR : les clés du premier arbre sont toutes strictement inférieures à celles du deuxième.
De même que pour l’insertion, si le nœud à supprimer n’est pas présent dans l’arbre, celui-ci est renvoyé à
l’identique.
4. On rappelle que les clés sont supposées distinctes.
• Suppression du maximum du sous-arbre gauche : supposons à nouveau que la racine x de soit l’élément à
supprimer. Si le sous-arbre gauche est vide, l’arbre obtenu après suppression est simplement le sous-arbre droit.
Sinon, on peut remplacer la racine par le maximum de son sous-arbre gauche (préalablement supprimé) : on
maintient bien ainsi la structure d’ABR. Ainsi, on écrit d’abord une fonction qui renvoie le maximum d’un arbre
binaire de recherche : il suffit de descendre le plus à droite possible.
let rec max_abr a=match a with
| Vide -> failwith "vide"
| N(_,x,Vide) -> x
| N(_,_,d) -> max_abr d
;;
Complexité des opérations. Toutes les opérations insertion/suppression/recherche prennent un temps O(h), avec
h la hauteur de l’arbre. Malheureusement, h peut être proche du nombre de nœuds, ce qui est pas mauvais en terme
de complexité. Par exemple, en partant d’un arbre vide, si on insère successivement n nœuds dans l’ordre croissant,
on obtient un arbre de hauteur n − 1, et la construction se fait en temps O(n2 ) (on construit un « peigne »).
Il y a plusieurs solutions pour remédier à ce problème :
— si on se donne un ensemble de clés que l’on insère dans un ordre aléatoire (avec distribution uniforme sur les
n! permutations possibles), on peut montrer que l’espérance de la hauteur de l’arbre obtenu est O(log n) : en
pratique les choses se passent bien si on laisse faire le hasard ;
— on peut rajouter de l’information dans l’ABR. Ces informations permettent, en utilisant des « rotations » bien
choisies, d’équilibrer l’arbre pour garder une hauteur O(log n).
La suite du chapitre est dévolue à l’étude des arbres AVL, qui s’inscrivent dans la stratégie du deuxième point.
Remarque 8.22. log 1(ϕ) ' 1.44, donc asymptotiquement un arbre AVL à n nœuds a une hauteur majorée par une
2
quantité de l’ordre de 1.44 log2 (n). On n’est pas très loin des arbres binaires complets pour lesquels la hauteur est
bornée par log2 (n).
rotation droite y
x
y γ α x
α β β γ
rotation gauche
Figure 8.7 – Rotations dans un ABR : x et y sont des nœuds, α, β et γ des ABR.
déséquilibre qui fait perdre la structure d’arbre AVL. Comme insertion et suppression font varier les hauteurs des
sous-arbres d’au plus 1, s’il y a déséquilibre c’est qu’un nœud (dont l’arbre associé a pour hauteur h) possède deux
sous-arbres de hauteurs respectives h − 1 et h − 3. On suppose que le sous-arbre de hauteur h − 1 est celui de gauche,
l’autre cas s’en déduit par symétrie. Le sous-arbre de gauche se décompose en une racine (y) et deux sous-arbres
gauche et droit α et β. On distingue alors deux cas :
• la hauteur de α est supérieure ou égale à celle de β (voir figure 8.8). Ainsi h(α) = h − 2 et h(β) = h − 2 − p, avec
p ∈ {0, 1}. Une rotation droite suffit alors, et l’arbre total passe d’une hauteur h à h − p : si p = 1 le déséquilibre
se propage potentiellement plus haut.
• la hauteur de α est strictement inférieure à celle de β (voir figure 8.9). β se décompose en une racine (z) et deux
sous-arbres δ et ε, de hauteurs respectives h − 3 − p et h − 3 − q, avec p, q ∈ {0, 1} l’un au moins étant nul.
On vérifie qu’une rotation gauche sur le sous-arbre enraciné en y nous ramène (presque) au cas précédent. Une
rotation droite équilibre l’arbre total, qui passe d’une hauteur h à une hauteur h − 1.
x h y h−p
y γ rotation droite
h−1 h−3 h−2 α x h−1−p
Figure 8.8 – Déséquilibre dans un arbre AVL, cas 1 : une rotation droite suffit.
Le champ entier indique la hauteur du sous-arbre associé au nœud. Ainsi on n’a pas à recalculer la hauteur des sous-
arbres d’un arbre à équilibrer (cette opération serait linéaire en le nombre de nœuds, donc très mauvaise car on veut
une complexité logarithmique pour les opérations d’ABR !). La discussion précédente permet d’écrire une fonction
equilibrer : 'a avl -> 'a avl, qui prend en paramètre un arbre qui est presque un AVL : les sous-arbres gauche
et droit sont supposés être des AVL de hauteur qui diffèrent d’au plus 2. Le champ hauteur de l’arbre est également
possiblement erroné. La fonction procède si nécessaire à une ou deux rotations, et renvoie un arbre AVL (avec champ
hauteur correct), et ce en temps constant. Une fois écrite cette fonction, il est facile de réécrire des versions pour arbres
AVL des fonctions sur les ABR. Voici par exemple le code de la fonction d’insertion :
let rec inserer a x=match a with
| Vide -> N(0,Vide,x,Vide)
| N(h,g,y,d) when y=x -> a
| N(h,g,y,d) when y<x -> equilibrer (N(h,g,y,inserer d x))
| N(h,g,y,d) -> equilibrer (N(h,inserer g x,y,d))
;;
À l’aide du théorème 8.21 on voit que l’on a bien créé une structure de dictionnaire où toutes les opérations s’effectuent
en temps logarithmique en le nombre d’entrées.
x h x h
z h−1
rotation droite
h−2 y x h−2
h−3 α δ ε γ h−3
h−3−p h−3−q
Figure 8.9 – Déséquilibre dans un arbre AVL, cas 2 : une rotation gauche sur le sous-arbre gauche nous ramène
quasiment au cas précédent, une rotation droite résout le déséquilibre. La hauteur de l’arbre a diminué de 1.
Remarque 8.23. Une autre implémentation classique des dictionnaires est celle faisant usage d’une table de hachage.
Chapitre 9
9.1 Introduction
Nous avons définis précédemment un arbre, comme un objet mathématique précis : un ensemble muni d’une certaine
relation binaire (de parenté). Ceci dit, lors de l’implémentation concrète, il a fallu s’éloigner de cette définition pour
ordonner les fils. Une autre possibilité (peut-être plus naturelle) est de donner une définition inductive : un arbre est
soit réduit à un sommet, soit constitué de sa racine est de la liste (ordonnée !) des sous-arbres de la racine. On pourrait
donner des définitions du même style pour les autres types d’arbres un peu plus restreints comme les arbres binaires.
Ce genre de définition a le mérite d’être facilement manipulable, à la fois pour prouver des propriétés mathématiques
(par exemple, un arbre binaire entier possède une feuille de plus que de nœuds internes) et transposable aisément en
une implémentation.
Dans la suite, on utilise la notion de mot sur un alphabet A (fini ou dénombrable), qui sera vue plus tard. Ce dont
on a besoin est assez intuitif : un mot sur A est un n-uplet d’éléments de A, qu’on préfère noter a1 a2 . . . an plutôt que
(a1 , . . . , an ). L’entier n peut être nul : on note ε le mot vide. Voici deux exemples : abcaab est un mot sur {a, b, c}, et
(4 + 5) × 2 un mot sur N ∪ {+, ×, (, )}.
Remarque 9.1. L’ensemble E ne joue pas un rôle prépondérant. Néanmoins, il est nécessaire de supposer son exis-
tence, pour éviter de se retrouver coincé par des considérations du type « ensemble de tous les ensembles », qui n’existe
pas. Pour l’ensemble E, on prendra souvent l’ensemble des mots sur un certain alphabet, ensemble qui a le mérite
d’exister, en confondant les objets avec leurs écritures syntaxiques.
Démonstration. L’ensemble des sous-ensembles de E vérifiant les deux propriétés (B) et (I) est non vide, car il contient
E lui-même. On peut donc considérer X, l’intersection de tous ces sous-ensembles. Alors :
— X vérifie (B) ;
— si (x1 , . . . , xnj ) est un nj -uplet d’éléments de X appartenant à l’ensemble de définition de rj , alors par définition
rj (x1 , . . . , xnj ) appartient à l’intersection de tous les sous-ensembles de E vérifiant (B) et (I), donc à X.
X est bien le plus petit sous ensemble de E vérifiant (B) et (I).
Définition 9.4. L’ensemble X donné par le théorème précédent s’appelle l’ensemble défini par induction avec l’en-
semble de base B et les règles de R.
Notation. Dans la suite, on notera les définitions d’un ensemble inductif X comme ceci :
— (B) : B ⊂ X ;
— (I) : (x1 , . . . , xn ) ∈ X n ⇒ r(x1 , . . . , xn ) ∈ X, pour chaque règle.
Autres exemples. Les expressions rationnelles ou les formules logiques (vues plus tard) peuvent être définies de la
même façon.
— N ⊂ A;
— pour tout op ∈ {+, ×, −, /}, on a g, h ∈ A ⇒ (g op h) ∈ A ;
Par exemple, les deux dérivations différentes qui donnaient 3 − 4 + 5 précédemment donnent maintenant ((3 − 4) + 5)
et (3 − (4 + 5)).
Proposition 9.19. La fermeture réflexive-transitive d’une relation est une relation réflexive et transitive.
Démonstration. Évident ! Pour la réflexivité, remarquer que l’on peut prendre n = 0 dans la définition.
Théorème 9.20. Soit X un ensemble inductif défini de manière non ambiguë. On introduit la relation ≺1 telle que
xi ≺1 r(x1 , . . . , xn ) pour toute règle d’inférence r d’arité n, et tout n-uplet (x1 , . . . , xn ). Notons la fermeture
réflexive transitive de la relation ≺1 . Alors est une relation d’ordre sur X.
Démonstration. Il ne reste à montrer que l’anti-transitivité. Mais si x y et y x, alors x = y car sinon la
non ambiguïté serait contredite, puisqu’on pourrait obtenir x à nouveau à partir de x et d’une suite non triviale
d’applications des règles d’inférence.
On rappelle qu’un ensemble ordonné est bien fondé s’il ne possède pas de suite infinie strictement décroissante.
C’est le cas ici.
Proposition 9.21. Sous les hypothèses du théorème précédent, l’ensemble (X, ) est bien fondé.
Démonstration. On peut construire une suite de sous-ensembles de X de la façon suivante :
Alors X = ∪i>0 Xi . En effet, en notant Y cette union, on voit facilement que les Xi sont inclus dans X, donc Y aussi,
et comme Y contient les éléments de base B et et est stable par les règles, il contient X. La non ambiguïté implique
que les Xi sont disjoints, et par définition de , si x ≺ y avec x ∈ Xp et y ∈ Xq , alors p < q. Une suite strictement
décroissante démarrant par un élément de Xp est donc de longueur au plus p + 1.
Autre preuve. On note P (x) la propriété sur X définie par « x n’est pas à l’origine d’une suite infinie strictement
décroissante ».
— P est vérifiée pour x dans B. C’est immédiat par non ambiguïté, car aucun élément y de X ne vérifie y ≺ x.
— Montrons que P est héréditaire. Soit x1 , . . . , xn vérifiant P , dans l’ensemble de définition d’une règle r d’arité
n, et soit y = r(x1 , . . . , xn ). Supposons y à l’origine d’une suite strictement décroissante pour l’ordre , notée
y = y0 y1 y2 · · · . Par définition de , il existe m > 0 et des éléments yj0 tels que y0 1 y10 1 · · · 1 ym
0
= y1 .
0
Puisque X est non ambigü, y1 est l’un des xi , et en particulier xi y1 y2 · · · , donc xi est à l’origine d’une
suite infinie strictement décroissante. C’est absurde, d’où l’hérédité de P .
Par principe d’induction, (X, ) est bien fondé.
Démonstration. Il suffit de montrer que ces hypothèses définissent la valeur f (x) de manière unique, propriété sur les
éléments de X que l’on montre par induction :
— c’est vrai pour les éléments x de B ;
— Tout autre élément x de X s’obtenant de manière unique sous la forme x = r(x1 , . . . , xn ), f (x) est bien défini
de manière unique.
Exemple 9.23 (Suite de l’exemple 9.8). On peut définir la hauteur h d’un arbre binaire de la façon suivante : l’abre
vide a pour hauteur −1, et ensuite h(Noeud(g, x, d)) = 1 + max(h(g), h(d)).
Chapitre 10
Logique
10.1 Introduction
Trois collègues déjeunent ensemble à midi. A, B, C. Les faits suivants sont vrais :
— si A prend un dessert, B aussi ;
— soit B, soit C prennent un dessert, mais pas les deux ;
— A ou C prend un dessert ;
— si C prend un dessert, A aussi.
On va voir que A et B prennent un dessert, pas C.
∧ ∨
¬ ∨ ∧ ¬
v1 v2 ¬ v1 0 ∨
v3 ∧ ¬
v3 v1 v2
10.2.4 Implémentation
On utilise naturellement une implémentation proche de la structure d’arbres pour définir un type exp_log. On
utilise un type polymorphe pour pouvoir avoir des variables logiques indexées par des entiers, des chaînes de caractères,
ou encore tout type à notre convenance.
Voici deux fonctions 1 permettant de calculer la hauteur et la longueur d’une expression logique :
∧ 0 1 ∨ 0 1
0 0 0 0 0 1
1 0 1 1 1 1
Remarque 10.7. L’ensemble {0, 1}, muni des deux lois de compositions internes ∧ et ∨ a une structure dite d’ algèbre
de Boole. Ce n’est pas une algèbre au sens usuel (il faudrait utiliser ⊕, défini plus loin, à la place de ∨ pour obtenir
une (Z/2Z)-algèbre).
Définition 10.8. La table de vérité d’une expression logique ϕ sur V est la donnée des Ed (ϕ) pour toute distribution
de vérité d sur V .
On représente en général la table de vérité comme un tableau, les lignes indexées par les distributions de vérité, et
les colonnes par les éléments de V et ϕ.
Exemple 10.9. Voici la table de vérité de l’expression ϕ = ¬v1 ∨ (v2 ∧ ¬v3 ) sur V = {v1 , v2 , v3 } :
v1 v2 v3 ϕ
0 0 0 1
0 0 1 1
0 1 0 1
0 1 1 1
1 0 0 0
1 0 1 0
1 1 0 1
1 1 1 0
Définition 10.10. Deux expressions ϕ et ψ sur V sont dites sémantiquement équivalentes si elles ont la même table
de vérité. On notera ϕ ≡ ψ dans ce cas.
n
Proposition 10.11. À équivalence sémantique près, il y a (au plus) 22 expressions logiques sur un ensemble de n
variables logiques.
n
Démonstration. Il y a 2n distributions de vérité possibles, donc au plus 22 tables de vérité associées à des expressions
logiques.
Remarque 10.12. On verra dans la suite comment construire une expression logique associée à une table de vérité,
n
il y a donc exactement 22 expressions logiques à équivalence sémantique près.
• Avec deux 1, il y a 6 formules logiques à équivalence sémantique près. Outre les formules v1 , ¬v1 , v2 et ¬v2 , il y
en a deux autres : v1 ∧ v2 ∨ ¬v1 ∧ ¬v2 et ¬v1 ∧ v2 ∨ v1 ∧ ¬v2 :
v1 v2 v1 ∧ v2 ∨ ¬v1 ∧ ¬v2 ¬v1 ∧ v2 ∨ v1 ∧ ¬v2
0 0 1 0
0 1 0 1
1 0 0 1
1 1 1 0
On notera v1 ⇔ v2 toute formule logique sémantiquement équivalente à v1 ∧ v2 ∨ ¬v1 ∧ ¬v2 et v1 ⊕ v2 toute
formule logique sémantiquement équivalente à ¬v1 ∧ v2 ∨ v1 ∧ ¬v2 . On prononce « v1 équivalente à v2 » et « v1
xor v2 ».
• Avec trois 1, il y en a 4 : v1 ∨ v2 , ¬v1 ∨ v2 , v1 ∨ ¬v2 et ¬v1 ∨ ¬v2 . On note v1 ⇒ v2 pour une expression logique
équivalente à ¬v1 ∨ v2 .
• Avec quatre 1, on retrouve la formule 1.
Définition 10.13. Lorsqu’on ne considère que les équivalences sémantiques, on note également :
— v1 ↑ v2 pour une formule équivalente à ¬(v1 ∧ v2 ) (pronconcer« nand ») ;
— v1 ↓ v2 pour une formule équivalente à ¬(v1 ∨ v2 ) (pronconcer« nor »).
Proposition 10.14 (Lois de De Morgan). On a v1 ↑ v2 ≡ ¬v1 ∨ ¬v2 et v1 ↓ v2 ≡ ¬v1 ∧ ¬v2 .
Proposition 10.17. Si ϕ est une expression tautologique en les variables v1 , . . . , vn , et ψ1 , . . . , ψn des expressions
logiques en les variables w1 , . . . , wp , alors l’expression logique obtenue en substituant ψi à vi pour tout i est une
tautologie en les variables w1 , . . . , wp .
Démonstration. Prenons une distribution de vérité d sur les variables w1 , . . . , wp . Puisque ϕ est une tautologie, l’éva-
luation de ϕ avec la distribution de vérité associant chaque vi à Ed (ψi ) donne 1. Ainsi l’expression logique obtenue en
substituant les ψi aux variables vi est bien une tautologie en w1 , . . . , wp .
Remarque 10.18. Il en va de même pour une antilogie, mais pas pour une expression satisfiable.
Définition 10.20. On dit qu’une expression logique est en forme normale disjonctive si c’est une disjonction de
conjonctions de littéraux.
Exemple 10.21. Avec V = {v1 , v2 , v3 }, l’expression ϕ = (v1 ∧v2 ∧¬v3 )∨(¬v1 ∧v2 ) est sous forme normale disjonctive :
c’est une disjonction de deux conjonctions de littéraux.
Définition 10.22. On appelle clause une disjonction de littéraux. On dit qu’une expression logique est en forme
normale conjonctive si c’est une conjonction de clauses (donc une conjonction de disjonctions de littéraux).
Exemple 10.23. v1 ∧ (¬v1 ∨ v2 ∨ v3 ) ∧ (v1 ∨ ¬v2 ) est sous forme normale conjonctive, composée de trois clauses à 1,
2 et 3 littéraux.
En général, on suppose que les variables apparaissant dans une clause ou une conjonction de littéraux sont toutes
différentes. En effet, d’une part on peut utiliser l’idempotence des opérateurs ∧ et ∨ pour supprimer les littéraux
égaux, et d’autre part une conjonction de littéraux comportant v et ¬v est sémantiquement équivalente à 0, de même
qu’une clause comportant v et ¬v est sémantiquement équivalente à 1.
Remarque 10.24. 0 est le neutre pour ∨ et 1 est le neutre pour ∧. On considère donc 0 comme une disjonction et 1
comme une conjonction.
Sans hypothèses supplémentaires, il existe plusieurs (une infinité, en fait) formes normales disjonctives ou conjonc-
tives équivalente à une expression logique donnée. Les formes normales sont en pratiques fournies en entrée des
algorithmes décidant si une expression logique est satisfiable, tautologique ou antilogique : il faut donc être capable
de mettre une expression sous forme normale.
On peut éliminer les littéraux en doublons dans les conjonctions de la forme c1i ∧ c2j , et éliminer totalement les
conjonctions contenant une variable et sa négation.
— La démonstration est la même pour ϕ1 ∨ ϕ2 , en remplaçant conjonction par disjonction, et en échangeant ∧ et ∨.
La démonstration du théorème précédent fournit une méthode pour calculer une forme normale (conjonctive ou
disjonctive), il suffit :
— de supprimer les 0 et les 1 ;
— de reformuler ⇒, ⊕, etc... à l’aide de ∨, ∧ et ¬ ;
— d’utiliser les lois de De Morgan pour faire rentrer ¬ dans les littéraux ;
— d’utiliser la distributivité de ∨ sur ∧ et celle de ∧ sur ∨ ;
— de simplifier en éliminant les doublons de littéraux dans une clause/conjonction de littéraux et en utilisant les
relations v ∧ ¬v ≡ 0 et v ∨ ¬v ≡ 1.
• Mettons par exemple le problème de l’introduction sous forme normale conjonctive. L’expression logique associée
est :
(A ⇒ B) ∧ (B ⊕ C) ∧ (A ∨ C) ∧ (C ⇒ A)
On reformule d’abord les opérateurs autres que ¬, ∧ et ∨ :
— A ⇒ B ≡ ¬A ∨ B ;
— B ⊕ C ≡ ¬B ∧ C ∨ B ∧ ¬C ;
— C ⇒ A ≡ ¬C ∨ A.
La formule obtenue est quasiment sous forme normale conjonctive, il ne reste plus qu’à utiliser les lois de De Morgan
sur la formule issue de B ⊕ C :
Cette dernière expression est à la fois sous forme normale disjonctive (une seule conjonction de trois littéraux) et sous
forme normale conjonctive (conjonction de trois clauses comportant un unique littéral).
Remarque 10.27. On remarque via l’exemple précédent qu’une forme normale n’est pas unique (on a montré
deux formes normales conjonctives équivalentes à la formule initiale). De plus, il n’est pas toujours facile d’obte-
nir une forme normale, car celle-ci peut être de taille exponentielle en la formule initiale. Par exemple, sur V =
{x1 , . . . , xn , y1 , . . . , yn } un ensemble de variables, l’expression
est sous forme normale disjonctive, et de taille O(n). On peut montrer qu’une forme normale conjonctive équivalente
est : ^
(z1 ∨ z2 ∨ · · · ∨ zn )
zi ∈{xi ,yi } pour 1≤i≤n
n
qui comporte 2 clauses, et on ne peut pas trouver plus petit.
Remarque 10.28. Il est courant d’alléger les expressions pour utiliser des formes arithmétiques plus proches de celles
qu’on utilise habituellement, ce qui a l’avantage de simplifier les écritures lorsqu’on travaille à équivalence sémantique
près. Ainsi :
— on remplace le ∧ par un point, voire par rien du tout ;
— on remplace le ∨ par + ;
— on écrit v̄ à la place de ¬v ;
— on s’autorise à écrire = à la place de ≡.
Cette forme rend plus intuitive la distributivité de ∧ sur ∨. Il ne faut cependant pas oublier l’autre distributivité,
l’absorbance de 1 pour ∨, l’idempotence, le fait que a + ā = 1, aā = 0 et les autres règles de simplification. Par
exemple :
(a + b)(a + b̄ + c) = a2 + ab + ab̄ + bb̄ + ac + bc = a(1 + b + b̄ + c) + bc = a + bc
Exemple 10.30. Avec n = 3, ¬v1 ∧ v2 ∧ ¬v3 est un min-terme. v1 ∧ ¬v2 et v1 ∧ ¬v1 ∧ v2 n’en sont pas.
Démonstration. En effet, pour m un min-terme, la seule distribution de vérité d telle que Ed (m) = 1 est celle donnée
1 si vi apparaît dans m
par : d(vi ) = .
0 si ¬vi apparaît dans m
Théorème 10.32. Soit ϕ une expression logique sur un ensemble fini de variables V . Alors ϕ est sémantiquement
équivalente à une unique (à l’ordre près) disjonction de min-termes différents (deux-min termes sont considérés comme
différents si les littéraux qui apparaîssent dans la conjonction diffèrent).
vi si d(vi ) = 1
où md est le min-terme associé à la distribution d, défini par md = `1 ∧ `2 ∧ · · · ∧ `n avec `i = .
¬vi si d(vi ) = 0
Pour l’unicité, il suffit de voir que si d est une distribution de vérité telle que Ed (ϕ) = 1, alors md est le seul min-terme
tel que Ed (md ) = 1, il doit donc apparaître dans la décomposition. Réciproquement si Ed (ϕ) = 0, alors md ne peut
apparaître dans l’écriture.
On peut paraphraser la preuve précédente en disant que pour trouver la FNDC équivalente à ϕ, il suffit de regarder
la table de vérité : chaque ligne avec un 1 fournit un min-terme apparaîssant dans la décomposition.
v1 v2 v3 v1 ⇒ ¬v2 v1 ∨ v3 ϕ
0 0 0 1 0 0
0 0 1 1 1 1
0 1 0 1 0 0
0 1 1 1 1 1
1 0 0 1 1 1
1 0 1 1 1 1
1 1 0 0 1 0
1 1 1 0 1 0
La négation établit une dualité entre les formes normales disjonctives et les formes normales conjonctives. Ce qui
suit est donc très proche de ce qu’on vient de voir sur les FNDC.
Définition 10.37. Un max-terme sur un ensemble de variables de taille n est une disjonction de n littéraux dans
laquelle chaque variable apparaît exactement une fois.
Théorème 10.40. Une expression logique sur un ensemble fini de variables V est sémantiquement équivalente à
une unique conjonction de max-termes différents. Cette écriture se nomme la forme normale conjonctive canonique
(FNCC).
Démonstration.
W Soit ϕ une expression logique sur V . Considérons la forme normale disjonctive canonique de ¬ϕ. Celle
ci s’écrit d | Ed (ϕ)=0 md . Ainsi, par négation
^
ϕ≡ (¬md )
d | Ed (ϕ)=0
Pour trouver la FNCC équivalente à ϕ, il suffit de regarder la table de vérité : chaque ligne avec un 0 fournit un
max-terme apparaîssant dans la décomposition.
Exemple 10.42 (Suite de l’exemple 10.35). La forme normale conjonctive canonique de ϕ = (v1 ⇒ ¬v2 ) ∧ (v1 ∨ v3 )
s’obtient en regardant les zéros dans sa table de vérité :
À partir d’une forme normale canonique (conjonctive ou disjonctive) il est facile de décider si une expression logique
est satisfiable / tautologique / antilogique. En effet :
Proposition 10.43. Soit ϕ une formule logique en un ensemble fini de variables. Alors :
— ϕ tautologie ⇐⇒ sa FNDC contient tous les min-termes ⇐⇒ sa FNCC ne contient aucun max-terme ;
— ϕ antilogie ⇐⇒ sa FNDC ne contient aucun min-terme ⇐⇒ sa FNCC contient tous les max-termes ;
— ϕ satisfiable ⇐⇒ sa FNDC contient au moins un min-terme ⇐⇒ sa FNCC ne contient pas tous les
max-termes.
Démonstration. Cela provient du fait que les min-termes sont liés aux uns dans la table de vérité, et les max-termes
aux zéros.
Le problème dans la mise en œuvre pratique (algorithmique) de cette proposition réside dans la taille potentielle-
ment exponentielle des formes normales canoniques : le nombre de min-termes dans la FNDC additionné au nombre
de max-termes dans la FNDD fait toujours 2n avec n le nombre de variables, donc au moins l’une des deux est de taille
exponentielle en n. Il n’est donc en général pas facile (algorithmiquement) de calculer ces formes normales canoniques.
Une idée pour résoudre ce problème est de tester toutes les distributions de vérité. On obtient un algorithme de
complexité Θ(2n `) avec ` la taille de ϕ. Mais peut-on faire mieux ? L’idéal serait un algorithme de complexité dans le
pire cas polynomiale en n (plus la taille de ϕ), c’est-à-dire de complexité O((n + `)k ) pour un certain k > 0.
Considérons un problème (à priori) plus simple :
Définition 10.44. Soit V un ensemble fini de variables. On rappelle qu’une clause est une disjonction de littéraux sur
V, chaque variable apparaissant au plus une fois. On dit que c’est une p-clause si p variables apparaissent exactement.
Définition 10.45. Le problème p-SAT pour p > 0 est la restriction du problème SAT sur les conjonctions de p-clauses.
Exemple 10.46. La reformulation (¬A ∨ B) ∧ (B ∨ C) ∧ (¬B ∨ ¬C) ∧ (A ∨ C) ∧ (¬C ∨ A) de l’expression logique issue
du problème de l’introduction est une instance du problème 2-SAT.
Proposition 10.47. Le nombre de p clauses sur un ensemble de n variables est polynomial en n, à p fixé.
Démonstration. En effet, il y a exactement np 2p p-clauses différentes : np correspond au choix des variables appa-
raîssant dans la clause, le facteur 2p correspond au choix de v ou ¬v pour chaque variable v apparaîssant dans la
p
clause. Or np 2p = 2p! n × (n − 1) × · · · × (n − p + 1) = O(np ) à p-fixé.
On peut supposer que les p-clauses d’une instance du problème p-SAT toutes différentes (il est très facile de
supprimer les doublons). On est donc ramené à la question : « Existe t-il un algorithme de complexité O(nk ) pour le
problème p-SAT, avec k > 0 ? »
Les réponses à cette question sont :
— si p = 1, oui : il suffit que la formule ne contienne pas à la fois vi et ¬vi ;
— si p = 2, oui : on verra un très joli algorithme dérivé d’algorithmes sur les graphes ;
— si p ≥ 3 : on ne sait pas. Celui qui résoudra la question recevra 1 million de dollars !
En fait, on peut montrer que le problème 3-SAT est aussi dur que les problèmes p-SAT et même que le problème
SAT lui-même (si l’on sait résoudre 3-SAT avec un algorithme de complexité polynomiale, on peut résoudre en temps
polynomial en la taille de l’entrée les problèmes p-SAT ou même SAT). Le problème 3-SAT fait partie de la grande
classe des problèmes NP (à vérification polynomiale : si l’on fournit une distribution supposée satisfaire Ed (ϕ) = 1, il
est très facile de le vérifier en temps polynomial en la taille de ϕ). Une autre classe de problèmes est celle des problèmes
résolubles en temps polynomial, qu’on note P. Clairement P ⊂ NP, mais la plus grande question de l’informatique
théorique est :
A-t-on P = NP ?
C’est pour la résolution de cette question que l’institut de mathématiques Clay offre 1 million de dollars. On peut en
fait montrer que le problème 3-SAT est NP-complet, c’est-à-dire qu’un algorithme de complexité polynomiale pour l’un
quelconque des problèmes NP-complets fournit un algorithme de complexité polynomiale pour n’importe quel problème
NP. Trouver un tel algorithme pour le problème 3-SAT fournirait donc une solution positive à la question. Cependant,
la plupart des informaticiens pensent que P 6= NP (et donc que 3-SAT ∈ / NP). En effet, on connaît aujourd’hui un très
grand nombre de problèmes NP-complets (par exemple le problème du sac à dos, le calcul du nombre chromatique
d’un graphe, résolution d’un système d’équations polynomiales...), mais toujours aucun algorithme de complexité
polynomiale dans le pire cas : il est donc probable qu’il n’en existe pas.
On ne peut donc raisonnablement pas espérer trouver dans le cadre du cours un algorithme polynomial pour
résoudre le problème SAT : on se contentera d’algorithmes de complexité exponentielle. Cependant, la complexité
exponentielle dans le pire cas d’un algorithme de résolution de SAT n’implique en rien qu’il soit systématiquement
difficile de résoudre SAT sur toutes les instances. Sur certaines « classes » d’instances particulières, il est possible de
trouver des algorithmes de complexité polynomiale en la taille de l’entrée.
Chapitre 11
11.1 Introduction
L’intérêt pour les graphes remonte au 18ème siècle d’un point de vue mathématiques. L’exemple historique est
le problème qu’Euler s’était posé dans la ville de Königsberg (aujourd’hui Kaliningrad). Peut-on partir d’un point
de la ville, faire une promenade en passant par tous les ponts une seule fois ? La réponse est non. On peut montrer
relativement facilement qu’un parcours de graphe eulérien (qui passe par toutes les arêtes, une seule fois) est possible
si et seulement si le nombre de sommets de degré impair est 0 ou 2, ce qui n’est pas le cas ici.
Figure 11.1 – Modélisation du problème des ponts de Königsberg par des graphes. Les images proviennent de Wiki-
pédia
Aujourd’hui, d’un point de vue pratique, les graphes sont présents partout :
— réseaux routiers ;
— réseaux de distribution d’eau, électricité ;
— Web et liens entre les pages ;
— Facebook et autres réseaux sociaux ;
— ...
Dans ce cours, on va s’intéresser aux questions algorithmiques, comme les suivantes.
— Comment parcourir un graphe ?
— Quelle est la plus courte distance entre 2 sommets d’un graphe ?
Mais on ne s’intéressera pas aux questions mathématiques.
— Combien de couleurs sont nécessaires pour colorer un graphe planaire sans que deux sommets adjacents aient la
même couleur ?
— Sous quelles conditions un graphe est-il plongeable (c’est-à-dire qu’il peut être représenté sans croisement) dans
le plan (ou la sphère, c’est le même chose), le tore, etc... ?
Chemins et cycles. Un chemin de longueur p dans le graphe est une suite de p + 1 sommets v0 , v1 , . . . , vp , telle que
{vi , vi+1 } ∈ E pour tout 0 ≤ i ≤ p − 1.
Définition 11.4. Un cycle de G est un chemin v0 , v1 , . . . , vp de longueur au moins trois, tel que v0 = vp et les sommets
(v0 , . . . , vp−1 ) sont distincts deux à deux 1 .
Définition 11.5. Un graphe est dit acyclique s’il ne possède pas de cycle.
On va caractériser par la suite les graphes acycliques : ce sont des forêts, c’est-à-dire des unions d’arbres (au sens des
graphes, définis ci-après).
Graphe induit et composantes connexes. Il est souvent utile de restreindre un graphe à un sous-ensemble de
sommets, ce qui mène à la définition suivante.
Définition 11.6. Soit G = (V, E) un graphe, et S ⊆ V un ensemble de sommets non vide du graphe. Le sous-graphe
de G induit par S est le graphe (S, A) avec A = E ∩ P2 (S).
Un graphe dans lequel il existe deux sommets non reliés par un chemin se décompose de manière non triviale en
sous-graphes, ce que l’on formalise avec les propriétés et définitions qui suivent.
Proposition 11.7. Dans G = (V, E), la relation uRv donnée par « il existe un chemin entre u et v » est une relation
d’équivalence sur V .
Démonstration. — La relation est réflexive : uRu, car u est un chemin de longueur 0 ;
— La relation est symétrique : si u = v0 , v1 , . . . , vp = v est un chemin de u à v, alors vp , vp−1 , . . . , v0 est un chemin
de v à u ;
— La relation est transitive : si u = v0 , v1 , . . . , vp = v et v = w0 , . . . , wq = w sont des chemins de u à v et de v à w,
on obtient un chemin de u à w en concaténant ces deux chemins.
Définition 11.8. Les composantes connexes du graphe sont les classes d’équivalence pour cette relation.
Remarque 11.9. Dans l’appelation « composante connexe », on confondera souvent l’ensemble des sommets et le
sous-graphe induit par cet ensemble.
Exemple 11.10. En figure 11.2 est illustré un graphe à trois composantes connexes.
1. Ce n’est pas forcément standard. Certains auteurs se contentent de v0 = vp . Mais pour parler de graphes acyliques comme dans la
suite, il faut éliminer les cycles « triviaux », comme par exemple un chemin parcouru dans les deux sens, ce qui est assez pénible à écrire
convenablement. Une autre définition possible est celle d’un chemin constitué d’arêtes distinctes dont les extrémités coïncident.
2 1 8
3 0 6 7 9
4 5
Définition 11.11. Un graphe est dit connexe s’il ne possède qu’une seule composante connexe. Autrement dit, pour
tout couple de sommets, il existe un chemin entre ces deux sommets.
Exemple 11.12. Voici deux graphes « classiques » sur [[0, n − 1]] (voir figure 11.3) :
— le cycle Cn , dont les arêtes sont {i, i + 1} pour tout i ∈ [[0, n − 2]] ainsi que {0, n − 1} ;
— la clique Kn , constituée des n2 arêtes possibles.
2 1 2 1
3 0 3 0
4 5 4 5
Démonstration. On va effectuer un double comptage : on note 1v,e le symbole d’incidence de l’arête e au sommet v,
c’est-à-dire :
1 si e incidente à v
1v,e =
0 sinon.
P P P P P
On a alors d’une part v∈V e∈E 1v,e = v∈V deg(v). D’autre part, e∈E v∈V 1v,e = 2 |E|.
2. Pourquoi ?
Corollaire 11.15. Déterminer le maximum d’une liste par comparaisons requiert au moins n − 1 comparaisons avec
n la taille de la liste.
On va maintenant parler des graphes qui possèdent des cycles, dans le but de caractériser les graphes acycliques.
Proposition 11.16. Soit G un graphe tel que le degré de chaque sommet soit au moins 2. Alors G possède un cycle.
Démonstration. On construit une suite de sommets dans le graphe en partant d’un sommet quelconque v0 , un voisin
v1 , et en posant pour tout i ≥ 1, vi+1 un voisin de vi qui n’est pas vi−1 . Cette construction est possible car le degré de
chaque sommet est au moins 2. Puisque V est fini, un sommet v apparaît au moins deux fois dans la suite (vi )i∈N , et
supposons que ce soit le premier à apparaître pour la deuxième fois. Notons alors i l’indice de sa première occurence,
et j l’indice de la deuxième. Alors le chemin vi , vi+1 , . . . , vj = vi ne contient que des sommets distincts : c’est un cycle
car j > i + 2 par construction.
Proposition 11.17. Un graphe acyclique d’ordre n possède au plus n − 1 arêtes.
Démonstration. La démonstration se fait également par récurrence sur n ≥ 1.
— Si n = 1, il n’y a rien à montrer.
— Supposons la propriété vraie jusqu’au rang n non inclus, et considérons un graphe acyclique d’ordre n. Alors
G possède un sommet v de degré 0 ou 1 (car sinon, il aurait un cycle d’après la proposition précédente). On
applique l’hypothèse de récurrence au graphe induit par V \{v} (évidemment acyclique !), qui est d’ordre n − 1
et possède donc au plus n − 2 arêtes. Ainsi G a bien au plus n − 1 arêtes.
— Par principe de récurrence, la propriété est bien démontrée.
Proposition 11.18. Soit G un graphe d’ordre n. Alors les propriétés suivantes sont équivalentes :
(1) il est acyclique et connexe ;
(2) il est acyclique et a n-1 arêtes ;
(3) il est connexe et a n-1 arêtes ;
Démonstration.
(1) ⇒ (2), (3) Puisque le graphe est connexe, il possède au moins n − 1 arêtes. Puisqu’il est acyclique, il en
possède au plus n − 1 ;
(3) ⇒ (1) Supposons que G = (V, E) possède un cycle v0 , . . . , vp . Tout chemin dans le graphe passant par
l’arête {v0 , v1 } peut être transformé en un chemin dont les arêtes sont dans E\{{v0 , v1 }}, en remplaçant l’arête
{v0 , v1 } par le chemin v0 = vp , . . . , v1 . Ainsi, supprimer {v0 , v1 } du graphe ne change pas sa connexité, ce qui
est absurde car avec n − 2 arêtes le graphe ne saurait être connexe.
(2) ⇒ (1) Notons r le nombre de composantes connexes de G, et n1 , . . . , nr le nombre de sommets de chaque
composante.
Pr Chaque composante étant acyclique et connexe, elle possède ni − 1 arêtes. On obtient donc au total
i=1 (n i − 1) = n − r arêtes, donc r = 1 et G est connexe.
Définition 11.19. Un arbre (au sens des graphes non orientés) est un graphe vérifiant l’une des conditions équivalentes
précédentes.
Remarque 11.20. Parmi les graphes, les arbres sont donc les graphes acycliques maximaux (rajouter une arête crée
un cycle) et connexe minimaux (enlever une arête fait perdre la connexité). La proposition précédente montre que les
composantes connexes d’un graphe acyclique sont des arbres, un graphe acyclique est donc également appelé une forêt.
2 1
3 0
4 5
Figure 11.4 – Un exemple d’arbre (au sens des graphes non orientés)
0 1 2 3 4
5 6 7 8 9
10 11 12 13
Degré entant et sortant. Puisque les arcs ont une orientation, on ne parle plus du degré d’un sommet v, mais de
son degré entrant (nombre d’arcs de la forme (u, v)) et de son degré sortant (nombre d’arcs de la forme (v, u)).
Chemin et circuit. Dans un graphe orienté, une suite v0 , v1 , . . . , vp telle que (vi , vi+1 ) ∈ E est également appelée
un chemin. On appelle circuit un chemin non réduit à un sommet, dont les sommets aux extrémités sont les mêmes.
Composantes fortement connexes. La relation R que l’on avait défini sur les graphes non orientés n’est plus
symétrique dans ce cadre. On remplace cette relation par la suivante : uR0 v si il existe un chemin de u à v et un
chemin de v à u, qui est bien une relation d’équivalence.
Définition 11.23. Les classes d’équivalence pour cette relation se nomment les composantes fortement connexes du
graphe.
0 1 2 3 4
5 6 7 8 9
10 11 12 13
Proposition 11.24. Les composantes fortement connexes d’un graphe orienté sans circuit sont réduites à des single-
tons.
Démonstration. Si u 6= v étaient dans la même composante fortement connexe d’un graphe G sans circuit, alors on
pourrait constuire un circuit en concaténant un chemin de u à v à un chemin de v à u, ce qui est absurde.
Il est intéressant de voir qu’on peut munir l’ensemble des composantes fortement connexes d’un graphe orienté d’une
structure de graphe sans circuit.
Proposition 11.25. Soit G = (V, E) un graphe orienté. Notons C l’ensemble des composantes fortement connexes,
et considérons GCFC = (C, E) avec E l’ensemble de couples de composantes fortement connexes distinctes vérifiant :
(C1 , C2 ) ∈ E si et seulement si il existe un arc entre un sommet de C1 et un sommet de C2 . Alors GCFC est un graphe
orienté acylique.
Démonstration. Supposons l’existence d’un circuit dans GCFC . Alors deux éléments C1 et C2 distincts du circuit sont
dans la même composante connexe de GCFC . Par définition, il existe v1 , v10 dans C1 et v2 , v20 dans C2 , tels que dans G
il y a un chemin de v1 à v2 et un autre de v20 à v10 . Par définition des composantes connexes, il y a aussi un chemin de
v10 à v1 et un autre de v2 à v20 dans G. Par suite, v1 , v10 , v2 , v20 sont sur un même circuit dans G, donc dans la même
composante fortement connexe : c’est absurde.
Exemple 11.26. En figure 11.7 est représenté le graphe des composantes fortement connexes du graphe précédent.
0 1 2 3 4
5 6 7 8 9
10 11 12 13
Arbre au sens des graphes orientés. Le travail fait sur les graphes non orientés montre que dans un arbre, il y
a essentiellement un seul chemin d’un sommet vers un autre (sinon on pourrait créer un cycle). Ainsi, le choix d’un
sommet particulier de l’arbre (on parle d’enracinement de l’arbre) permet d’orienter sans ambiguïté les arêtes pour
en faire un graphe orienté.
2 1
3 0
4 5
Remarque 11.27. Après enracinement, on retrouve les arbres définis « mathématiquement », c’est-à-dire comme
un ensemble muni d’une relation de parenté vérifiant les propriétés idoines. Ils sont toutefois différents des arbres
usuellement manipulés en informatique, car dans ce cas les fils d’un nœud sont ordonnés.
Ni la liste des voisins d’un sommet, ni la liste des sommets ne sont supposées ordonnées.
C E
D A
F B
Par exemple, le graphe ordonné de la figure précédente peut être implémenté comme suit.
[{id = "a"; voisins = ["b"; "e"]}; {id = "b"; voisins = ["a"; "f"]};
{id = "c"; voisins = ["d"]}; {id = "d"; voisins = ["a"; "f"]};
{id = "e"; voisins = ["b"; "c"]}; {id = "f"; voisins = ["c"; "e"]}]
Voici les fonctions d’ajout/suppression d’arcs et d’arêtes et de sommets que l’on peut écrire sur un tel graphe :
Ajout/suppression d’arcs/d’arêtes. On donne à chaque fois deux fonctions, la deuxième est valable pour un
graphe non orienté. Pour ajouter l’arête (u, v), il faut d’abord trouver le sommet d’index u et rajouter v dans sa
liste d’adjacence. Pour la suppression, on utilise suppr permettant de retirer si elle existe la première occurence d’un
élément dans une liste.
Ajout/suppression de sommets. Ajouter un sommet est facile, il est initialement sans voisins. Supprimer un
sommet est plus délicat qu’une arête car il faut supprimer le sommet de la liste, mais également toutes les apparitions
du sommet dans les listes d’adjacences des autres éléments. Par contre, il n’y a pas de distinction à faire entre les cas
orienté et non orienté.
(* est_present teste si l'étiquette u est déja présente *)
let est_present g u = [Link] u ([Link] (function x -> [Link]) g) ;;
Un mot sur l’efficacité. Une telle implémentation des graphes n’est pas très efficace, car il faut parcourir toute
la liste des sommets pour trouver celui qui nous intéresse. Ceci dit, ce type d’implémentation où les sommets sont
dynamiques est très utile lorsqu’on travaille sur un graphe dont l’ensemble des sommets n’est pas statique. C’est par
exemple le cas lorsqu’on explore une petite partie d’un très gros graphe en partant d’un sommet. Initialement, on va
travailler avec le sous-graphe contenant uniquement le sommet de départ, puis on va faire grossir le sous-graphe et
s’arrêter dès que nécessaire. Une application serait par exemple de chercher le plus court chemin entre vous et Barack
Obama dans Facebook : hors de question de charger en mémoire tout le graphe de Facebook, alors que le chemin entre
vous et Barack Obama est probablement inférieur à 6 ! 3 Une meilleure implémentation consiste à faire usage de tables
de hachages (ou plus généralement d’un dictionnaire) à la place d’une liste pour stocker les voisins d’un sommet :
retrouver un sommet se fait alors en temps constant en moyenne, sous certaines hypothèses naturelles.
2 1
3 0
4 5
Par exemple, le graphe ordonné de la figure précédente peut être implémenté comme suit.
let g=[| [1; 5]; [2; 5]; [3]; [0; 4]; [1; 2]; [0; 4] |] ;;
La liste d’adjacence du sommet i est simplement donnée par g.(i). Par contre les g.(i) ne sont pas supposées
ordonnées en général. Il est très facile de gérer l’ajout/suppression d’un arc dans un graphe représenté ainsi, on
pourrait ainsi écrire des fonctions d’ajout/suppression d’arcs/d’arêtes, très similaires aux précédentes.
Écrivons une fonction de désorientation du graphe, qui prend en entrée un graphe orienté et qui le modifie pour
rajouter si nécessaire les arcs (v, u) correspondant aux arcs (u, v) présents.
let desoriente g=
let n=[Link] g in
let ajoute g i j=if not ([Link] j g.(i)) then g.(i) <- j::g.(i) in
let rec aux i q=match q with
| [] -> ()
| j::p -> ajoute g j i ; aux i p
in
for i=0 to n-1 do
aux i g.(i)
done ;;
Remarque : si l’on ne souhaite pas modifier le graphe, on peut le copier simplement avec [Link].
Complexités. Cette représentation est également économique, car elle nécessite un espace mémoire en O(|V | + |E|).
Parcourir les voisins d’un sommet se fait en temps linéaire en son degré, par contre il faut parcourir une liste d’adjacence
pour tester l’existence d’un arc ou une arête. Là encore pour cette opération, utiliser des tables de hachage serait
préférable.
Définition11.28. La matrice d’un adjacence d’un graphe ([[0, n − 1]], E) est la matrice M = (mi,j )0≤i,j≤n−1 définie
1 si (i, j) ∈ E.
par mi,j =
0 sinon.
La diagonale de la matrice est constituée de zéros (il n’y a pas de boucles dans nos graphes). Pour un graphe non
orienté, la matrice est symétrique. En représentant ainsi un graphe en Caml, on obtient le type :
let m=[| [|0; 1; 0; 0; 0; 1|]; [|0; 0; 1; 0; 0; 1|]; [|0; 0; 0; 1; 1; 0|]; [|1; 0; 0; 0; 1; 0|];
[|0; 1; 1; 0; 0; 0|]; [|1; 0; 0; 0; 1; 0|] |] ;;
Écrivons une fonction de désorientation du graphe, similaire à la précédente mais sur des graphes représentés de
manière dense : il suffit de « symétriser » la matrice d’adjacence.
let desoriente m=
let n=[Link] m in
for i=0 to n-1 do
for j=0 to n-1 do
m.(i).(j) <- max m.(i).(j) m.(j).(i)
done
done
;;
Là encore, si on ne souhaite pas modifier le graphe, on peut faire une copie. Comme il est nécessaire de copier les
éléments de m, (tableau de tableaux), on propose la fonction suivante :
let copie m =
[Link] [Link] m
;;
avec [Link] de type ('a -> 'b) -> 'a array -> 'b array, appliquant une même fonction à tous les éléments
d’un tableau pour obtenir un nouveau tableau. Évidemment on pouvait faire une copie à la main en recréant une
matrice.
Complexités. Cette représentation n’est pas très économique en mémoire pour les graphes ayant peu d’arcs, car
2
elle nécessite toujours un espace en O(|V | ). Parcourir les voisins d’un sommet se fait en temps O(|V |) quel que soit
le degré du sommet, par contre tester l’existence d’un arc ou une arête se fait en temps constant.
let dense_vers_creux m=
let n=[Link] m in
let g=[Link] n [] in
for i=0 to n-1 do
for j=0 to n-1 do
if m.(i).(j)=1 then g.(i) <-j::g.(i)
done ;
done ;
g
;;
Si les opérations d’ajout et de retrait d’un élément dans a_traiter se font en temps constant, la complexité du
parcours générique est linéaire en O(|V | + |E|) (en fait plutôt que |E| c’est même le nombre d’arcs/arêtes du sous-
graphe induit par l’ensemble des sommets accessibles depuis s0 ). En effet, chaque arc (resp. arête) est exploré au plus
une fois (resp. 2 fois). L’algorithme précédent ne renvoie rien, mais on peut l’adapter pour obtenir des informations
sur le graphe : ceci dépend de la structure de données utilisée. Il y a deux choix naturels :
— une file mène à un parcours dit en largeur
— une pile mène à un parcours ressemblant au parcours en profondeur vu plus loin.
Montrons qu’un tel parcours permet d’explorer uniquement des sommets accessibles depuis un sommet s0 donné
(un sommet s est dit accessible depuis s0 s’il existe au moins un chemin de s0 à s). Vérifions qu’il les explore tous.
Proposition 11.30. Un parcours de graphe avec l’algorithme 11.29 lancé en s0 visite tous les sommets accessibles
depuis s0 .
Démonstration. Posons δ(s) = inf{ longueur d’un chemin de s0 à s} ∈ N∪{+∞}, défini pour tout sommet du graphe.
Naturellement, δ(s) < +∞ si s est accessible depuis s0 . Raisonnons par l’absurde et supposons qu’il existe au moins
un sommet s accessible depuis s0 mais non visité. Considérons un de ces sommets s tel que δ(s) est minimal. s 6= s0
car s est visité. Considérons un plus court chemin de s0 à s, noté s0 , s1 , . . . , sn = s. Clairement δ(si ) ≤ i, mais il y a
en fait égalité car sinon on obtiendrait un chemin plus court. Ainsi δ(sn−1 ) < δ(s), et sn−1 est visité par l’algorithme,
donc s l’est aussi.
0 1 2 3
4 5 6 7
Donnons nous une structure de file pour l’ensemble a_traiter. On va utiliser le module Queue de Ocaml, fournissant
les opérations classiques :
— [Link] : unit -> 'a Queue.t
— Queue.is_empty : 'a Queue.t -> bool
— [Link] : 'a -> 'a Queue.t -> unit
— [Link] : 'a Queue.t -> bool
En partant du sommet 0, tous les sommets sont accessibles. En supposant les listes d’adjacences données dans l’ordre
croissant, voici l’ordre dans lequel sont traités les sommets :
à traiter 0 1, 4 4, 2, 5, 6 2, 5, 6 5, 6, 3 6, 3 3, 7 7 vide
déja vus 0 0, 1, 4 0, 1, 2, 4, 5, 6 idem 0, 1, 2, 3, 4, 5, 6 idem tous tous tous
Une application du parcours en largeur est le calcul de plus courts chemins depuis l’origine s0 du parcours. Il suffit
de rajouter les deux informations suivantes dans le parcours en largeur :
— un tableau dist de distance à la source s0 . Lorsqu’on découvre un nouveau sommet t à partir d’un sommet s,
dist.(t) prend la valeur dist.(s)+1.
— un tableau pred de prédecesseur : si t est découvert à partir de s, on pose π(t) = s. Le tableau des prédecesseurs
fournit un plus court chemin entre la source s0 et tout sommet accessible t : il suffit de remonter via pred jusqu’à
s0 pour avoir le chemin à l’envers.
Avant de montrer que cette approche est correcte, donnons un code complet en Caml.
Dans le code précédent, tous les sommets à distance finie de s0 ont une valeur associée dans dist qui est positive :
c’est la distance d’un plus court chemin entre s0 et ce sommet. Le tableau pred permet de reconstruire facilement un
plus court chemin.
# let g = [|[1; 4]; [0; 2; 5; 6]; [3; 6]; [2; 7]; [5]; []; [7]; [6]|] ;;
val g : int list array =
[|[1; 4]; [0; 2; 5; 6]; [3; 6]; [2; 7]; [5]; []; [7]; [6]|]
# plus_courts_chemins g 0 ;;
- : int array * int array =
([|0; 1; 2; 3; 1; 2; 2; 3|], [|0; 0; 1; 2; 0; 1; 1; 6|])
Proposition 11.31. À la fin de l’algorithme, si s est un sommet accessible, dist.(s) contient la distance entre s0
et s, et le chemin formé par les prédecesseurs de s via le tableau pred est un plus court chemin de s0 à s.
Démonstration. Tout d’abord, les sommets sont insérés dans la file avec une distance au sommet s0 croissante : en
effet, ceci se montre facilement par récurrence sur la distance à s0 , en considérant la propriété P(d) : « les sommets à
distance d sont insérés dans la file avant tous ceux à distance au moins d + 1 ». P(0) est vraie, et si P(d) est vraie, un
sommet à distance d + 2 est inséré à partir d’un sommet à distance (au moins) d + 1, donc après traitement de tous
les sommets à distance d et donc insertion des sommets à distance d + 1, donc P(d) implique P(d + 1).
Considérons maintenant le chemin donné par les prédécesseurs dans le parcours, entre s0 et un sommet s 6= s0 ,
qu’on note s0 → s1 → · · · → sn = s. Considérons de plus un plus court chemin de s0 à s, qu’on écrit s0 t → s.
t a été inséré après sn−1 dans la file (car s est découvert par sn−1 ), donc δ(s0 , sn−1 ) ≤ δ(s0 , t) d’après la propriété
précédente. Ainsi δ(s0 , s) = δ(s0 , t) + 1 ≥ δ(s0 , sn−1 ) + 1, ce qui prouve que le chemin s0 → s1 → · · · → sn = s est un
plus court chemin de s0 à s.
Avant de parler du parcours en profondeur, montrons une propriété sur les prédecesseurs :
Proposition 11.32. Considérons le graphe non orienté induit par les sommets accessibles depuis s0 et les arêtes
{s, π(s)}. Ce graphe est un arbre.
0 1 2 3
4 5 6 7
Démonstration. Le graphe possède un sommet de plus que d’arêtes et est connexe : c’est donc un arbre.
On parle de l’arbre des prédecesseurs, voir figure 11.10. Il est en fait naturellement orienté vers la source.
En fait cela ne dépend pas de la structure de données utilisées et est valable également pour l’algorithme générique.
De plus, il est parfois utile de stocker plus d’informations pendant le parcours. On peut par exemple calculer les
dates de début et de fin de traitement des sommets : on utilise un compteur (entier) que l’on incrémente lorsqu’on
découvre un nouveau sommet où lorsqu’on a terminé de traiter l’un des sommets. On stocke les dates de début et de
fin de traitement dans des tableaux. De même que pour le parcours en largeur, il est courant de stocker le prédecesseur
d’un sommet dans un tableau. La définition du prédécesseur d’un sommet dans le parcours est la même que pour le
parcours générique : si v est découvert dans la boucle principale de la fonction pp appelée sur u, alors le prédecesseur
de v est u. Pour le même graphe que précédemment, le parcours élémentaire lancé depuis le sommet 0 découvre tout
le graphe, et on obtient l’arbre des prédécesseurs suivant de la figure 11.11.
0 1 2 3
4 5 6 7
On va voir dans la section suivante des variantes du parcours en profondeur permettant de résoudre les problèmes
suivants :
— calcul des composantes connexes d’un graphe non orienté ;
— tri topologique d’un graphe orienté sans circuit ;
— calcul des composantes fortement connexes d’un graphe orienté.
Complexité. Tout comme le parcours en largeur, le parcours en profondeur a une complexité O(|V | + |E|), pour les
mêmes raisons : l’initialisation de deja_vu prend un temps O(|V |), et ensuite la complexité est linéaire en le nombre
d’arêtes/arcs |E|.
let composantes_connexes g=
let n=[Link] g and liste_comp=ref [] and comp=ref [] in
let deja_vu=[Link] n false in
let rec pp y=match deja_vu.(y) with
| true -> ()
| false -> deja_vu.(y) <- true ; aux g.(y) ; comp:=y::!comp ;
and aux p=match p with
| [] -> ()
| z::q -> pp z ; aux q
in
for i=0 to n-1 do
if not deja_vu.(i) then begin
pp i ;
liste_comp:=!comp::!liste_comp ;
comp:=[]
end
done ;
!liste_comp
;;
# let g = [|[1; 2; 4; 5]; [0; 2]; [0; 3]; [2]; [0; 5]; [0; 4]; [7]; [6; 8]; [7]; []|] ;;
val g : int list array =
[|[1; 2; 4; 5]; [0; 2]; [0; 3]; [2]; [0; 5]; [0; 4]; [7]; [6; 8]; [7]; []|]
# composantes_connexes g ;;
- : int list list = [[9]; [6; 7; 8]; [0; 4; 5; 1; 2; 3]]
La proposition précédente nous dit que l’on peut ordonner les sommets d’un graphe orienté sans circuit « en ligne »,
de sorte que tous les arcs aillent de gauche à droite. Avant de montrer ce théorème, énonçons et montrons le lemme
suivant.
Lemme 11.35. Dans un graphe orienté sans circuit, il existe un sommet de degré sortant nul.
Démonstration. Par l’absurde, si tout sommet était de degré sortant non nul, alors on pourrait construire une suite
(vi )i∈N de sommets de la façon suivante :
— v0 un sommet quelconque.
— vi un voisin quelconque de vi−1 pour i ≥ 1 (dans un graphe orienté, cela signifie (vi−1 , vi ) ∈ E).
Puisque le nombre de sommets est fini, un sommet au moins apparaît deux fois, ce qui fournit un circuit : c’est
absurde.
Preuve du théorème 11.34. On montre cette propriété par récurrence sur le nombre de sommets n.
— si n = 1, c’est trivial.
— supposons donc n ≥ 2, et considérons un graphe G = (V, E) d’ordre n, sans circuit. Posons sn−1 un sommet de
degré sortant nul. Le graphe induit par V \{sn−1 } est sans circuit, il existe donc par hypothèse de récurrence
une énumération s0 , . . . , sn−2 des sommets de ce graphe dont les arcs sont de la forme (si , sj ) avec i < j. Ainsi,
s0 , . . . , sn−1 est une énumération convenable des sommets de G, car les arcs impliquant sn−1 sont orientés vers
sn−1 .
— Par principe de récurrence, le théorème est démontré.
Évidemment, la réciproque de ce théorème est vraie car un circuit rend une telle énumération impossible.
Définition 11.36. Une énumération s0 , . . . , sn−1 des sommets d’un graphe sans circuit, telle que si (si , sj ) ∈ E alors
i < j, se nomme un ordre topologique du graphe.
On a montré l’existence d’un ordre topologique dans un graphe sans circuit, et en fait la démonstration du théorème
fournit un algorithme pour en calculer un. Il est possible d’implémenter cette idée avec une complexité O(|V | + |E|),
mais la proposition suivante indique comme en calculer un facilement à l’aide d’un simple parcours en profondeur.
Proposition 11.37. Considérons un parcours en profondeur complet d’un graphe sans circuit G = (V, E), dans lequel
les dates de fin de parcours sont stockées. Ordonner les sommets par date de fin de parcours décroissante fournit un
ordre topologique du graphe.
Démonstration. Considérons deux sommets u et v du graphe, tels que (u, v) ∈ E.
— si le parcours en profondeur découvre u avant v, alors comme v est voisin de u, v est découvert pendant le
parcours de u, et donc l’exploration depuis v termine avant celle de u.
— si v est découvert avant u, comme il n’existe pas de chemin dans le graphe reliant v à u (sinon il y aurait un
circuit) alors l’exploration depuis v termine également avant celle de u.
Par suite, l’énumération suivant les dates de parcours décroissantes fournit bien un ordre topologique.
À l’inverse, il est également possible de détecter facilement l’existence d’un circuit dans un graphe orienté, en
stockant également les dates de début de parcours en profondeur. En effet, considérons dans le parcours en profondeur
d’un graphe ayant un circuit le premier sommet s0 contenu dans un circuit à être découvert. Notons s0 , s1 , . . . , sn−1 = s0
ce circuit. Tous les sommets (si )1≤i≤n−2 seront découverts pendant le parcours en profondeur de s0 , et l’arc (sn−1 , s0 )
sera examiné pendant ce parcours : lorsque c’est le cas, les parcours en profondeur des deux sommets ne sont pas
terminés, et la date de début de parcours de sn−1 est postérieure à celle de s0 , ce qui signifie qu’il y a un chemin de
s0 à sn−1 : on peut donc détecter le circuit.
En fait, il est inutile de stocker des informations aussi précises pour le calcul d’un ordre topologique ou la détection
de circuit : il suffit d’attribuer 3 couleurs aux sommets pendant le parcours en profondeur :
— blanche pour un sommet non découvert ;
— grise pour un sommet découvert mais donc le parcours en profondeur est en cours ;
— noire pour un sommet dont le parcours en profondeur est terminé.
Un arc qui mène d’un sommet gris à un autre indique l’existence d’un circuit. S’il n’en existe pas, stocker les sommets
au fur et à mesure qu’on leur attribue la couleur noire donne un ordre topologique (dans l’ordre inverse où on les
stocke), voir algorithme 11.38.
Exemple 11.39 (Comment s’habiller le matin ?). Il faut aider le savant Cosinus à s’habiller. Dans le graphe de
la figure 11.12, un arc entre le vêtement u et le vêtement v indique qu’il faut impérativement enfiler u avant v
(typiquement, le caleçon avant le pantalon, les chaussettes avant les chaussures...). Mais dans quel ordre enfiler tout
ça ? Un tri topologique fournit la solution. En lançant un parcours en profondeur complet sur les vêtements (de haut
en bas et de gauche à droite), on obtient les dates de début et dates de fin de parcours de chaque nœud indiqué à côté
des vêtements. Par ordre décroissant de date de fin du parcours en profondeur, on obtient l’ordre topologique de la
figure 11.13, qui n’est pas forcément l’ordre le plus naturel, mais fonctionne !
11.5.3 Calcul des composantes fortement connexes d’un graphe orienté (HP)
Le calcul des composantes fortement connexes d’un graphe orienté est plus technique que le calcul des composantes
connexes d’un graphe non orienté : en effet, lorsqu’on lance un parcours en profondeur élémentaire depuis un sommet
dans un graphe non orienté, les sommets découverts sont uniquement ceux de sa composante connexe, alors que dans
un graphe orienté on découvre tous les sommets des composantes fortement connexes accessibles depuis le sommet.
On présente ici un algorithme dû à Kosaraju qui fait usage de deux parcours en profondeur pour calculer les
composantes fortement connexes. L’un se fait sur le graphe, l’autre sur le graphe transposé, qui est le graphe obtenu
en changeant le sens des arcs :
Définition 11.40. Soit G = (V, E) un graphe orienté. Le graphe transposé de G, noté t G est le graphe (V, t E) où
t
E = {(v, u) | (u, v) ∈ E}.
Une propriété essentielle pour le bon fonctionnement de l’algorithme est le lemme suivant.
Lemme 11.41. Pour G un graphe orienté, les partitions des sommets de G et t G données par les composantes
fortement connexes sont les mêmes.
Démonstration. Si u et v sont dans une même composante fortement connexe dans G, cela signifie qu’il existe un
chemin de u à v et un chemin de v à u. En changeant le sens des arcs, on obtient un chemin de v à u et un chemin de u
à v dans t G, donc u et v sont dans une même composante fortement connexe dans t G. Comme tt G = G, la réciproque
est vraie, d’où le lemme.
Avant de montrer qu’il est correct, effectuons un exemple complet sur le graphe de la figure 11.14.
0 1 2 3 4
5 6 7 8 9
10 11 12 13
Figure 11.14 – Un graphe dont on veut calculer les composantes fortement connexes
Le tableau suivant donne les dates de début et fin de parcours en profondeur du graphe, en supposant que les listes
d’adjacence sont triées dans l’ordre croissant (on effectue donc un premier parcours en profondeur élémentaire depuis
0, puis un autre depuis 3). Dans le tableau, les sommets sont ordonnés par date de découverte (début de l’exploration)
croissante.
sommet 0 1 5 6 10 11 7 8 2 12 13 3 4 9
debut 0 1 2 3 5 7 8 9 10 14 15 22 23 24
fin 21 20 19 4 6 18 13 12 11 17 16 27 26 25
L’énumération des sommets, ordonnés par date de fin de parcours en profondeur décroissante est donc :
0 1 2 3 4
5 6 7 8 9
10 11 12 13
Proposition 11.43. Soit G un graphe orienté, notons C1 , . . . , Ck ses composantes fortement connexes. Lors du
parcours en profondeur de G, notons vi le premier sommet de Ci à être découvert. Quitte à réordonner les composantes,
supposons que v1 , . . . , vk soient ordonnés par date de fin de parcours décroissante. Alors C1 , . . . , Ck est un ordre
topologique du graphe des composantes fortement connexes.
Démonstration. La démonstration est essentiellement la même que pour montrer que le parcours en profondeur fournit
un ordre topologique dans un graphe orienté sans circuit. Soit C et C 0 deux composantes fortement connexes, notons c
et c0 les deux sommets découverts en premier dans les deux composantes, et supposons qu’il existe un arc entre C et
C0.
— si c est découvert avant c0 dans le parcours en profondeur, le parcours depuis c découvre tous les sommets de C,
et donc un arc entre C et C 0 , et donc toute la composante C 0 . Ainsi le parcours en profondeur depuis c termine
après celui depuis c0 ;
— si c0 est découvert avant c, comme il n’existe pas de chemin entre c0 et un sommet quelconque de C, le parcours
en profondeur découvrira c après que le traitement de c0 soit terminé. Ainsi le parcours en profondeur depuis c
termine après celui depuis c0 .
Théorème 11.44. L’algorithme de Kosaraju fournit bien les composantes fortement connexes d’un graphe orienté.
Démonstration. On reprend les notations de la proposition 11.43. Puisqu’on traite dans la boucle principale du parcours
en profondeur sur t G les sommets par date de fin de parcours sur G décroissante, chaque nouveau parcours en
profondeur lancé dans la boucle principale l’est sur l’un des sommets vi , en commençant par v1 . De plus puisque
C1 , . . . , Ck forme un ordre topologique des composantes connexes de G, un ordre topologique des composantes connexes
de t G est Ck , . . . , C1 . Ainsi, le parcours lancé sur v1 ne découvre que C1 , celui sur v2 ne découvre que C2 , etc...
L’algorithme de Kosaraju est bien correct.
Proposition 11.45. Le calcul des composantes fortement connexes d’un graphe orienté G = (V, E) via l’algorithme
de Kosaraju se fait en complexité O(|V | + |E|).
Démonstration. L’algorithme consiste à effectuer deux parcours en profondeur sur G et t G, ce qui se fait en complexité
O(|V | + |E|). Le calcul de t G se fait également avec une complexité O(|V | + |E|) (il faut parcourir toutes les listes
d’adjacence et en créer de nouvelles), d’où le résultat.
Exemple 11.49 (Exemple des desserts du cours de logique). On avait vu que le problème introductif du cours de
logique pouvait se reformuler sous l’instance 2-SAT suivante : (¬A ∨ B) ∧ (B ∨ C) ∧ (¬B ∨ ¬C) ∧ (A ∨ C) ∧ (¬C ∨ A).
En reformulant chacune des clauses sous la forme de deux implications, on obtient le graphe suivant :
A C
B ¬B
¬C ¬A
¬A, ¬B, C A, B, ¬C
Ainsi, une valuation qui satisfait la formule est donc d(A) = 1, d(B) = 1 et d(C) = 0 (et c’est d’ailleurs la seule).
Exemple 11.50 (Autre exemple). On considère (a ∨ b) ∧ (¬a ∨ c) ∧ (¬b ∨ a) ∧ (¬b ∨ c) ∧ (¬a ∨ ¬c).
a c
b ¬b
¬c ¬a
Les 6 littéraux sont tous dans la même composante fortement connexe : la formule logique n’est pas satisfiable.
11.6.3 Complexité
Notons c le nombre de clauses apparaîssant dans la formule, et notons n le nombre de variables logiques. Notons
que n = O(c). Le graphe se construit en temps O(n + c) = O(c), et le calcul des composantes connexes et d’un ordre
topologique (dans le cas où la formule est satisfiable) se fait également en temps O(n + c) = O(c). Le calcul d’une
distribution de vérité se fait ensuite en temps O(n). Ainsi, on obtient un algorithme de complexité O(n + c) = O(c)
pour la résolution du problème 2-SAT, qui se résout donc en temps linéaire.
Chapitre 12
Graphes pondérés
12.1 Introduction
Dans ce chapitre, on considère des graphes où les arêtes sont munies d’un poids. Les applications sont nombreuses :
— dans le plan euclidien, si on considère un nuage de points, on peut considérer le graphe complet sur ce nuage,
les arêtes ont alors pour poids la distance entre ces sommets.
— une variante s’applique aux problématiques du transport : on a un réseau de routes (des arcs), dont le poids est
la distance entre ces villes. Les problèmes de plus courts chemins apparaîssent naturellement dans ce contexte.
— un poids sur une arête/un arc peut modéliser une capacité de flux, etc...
On a vu dans le chapitre précédent comment calculer la distance δ(s, t) entre un sommet source s et un sommet
quelconque t dans un graphe non pondéré, à l’aide d’un parcours en largeur. Le but de ce chapitre est de généraliser
cet algorithme au cas des graphes pondérés.
12.2.2 Implémentation
En pratique, on se ramène très souvent au cas où les poids sont des entiers. L’implémentation en Caml est assez
immédiate.
• Dans le cas d’une implémentation creuse, il suffit dans la liste d’adjacence d’un sommet u, de stocker des couples
(v, p) à la place du seul sommet v : ceci signifie que (u, v) ∈ E et ω(u, v) = p. Le type utilisé est donc le suivant
avec des poids entiers :
type graphe_pondere_creux = (int * int) list array ;;
• Dans le cas d’une implémentation dense, on utilise la généralisation de la fonction ω à tout couple de sommets :
la matrice d’adjacence est maintenant une matrices à valeurs dans R ∪ {+∞}. En pratique, comme les poids sont
souvent des entiers, on utilise plutôt une matrice à coefficients dans Z ∪ {+∞}, ce qui mène au type suivant en
Caml :
type zbar = Z of int | Inf ;;
type graphe_pondere_dense = zbar array array ;;
−4
5
0 1 0 5 −4 8 +∞
-6 -2 −2 0 +∞ +∞ +∞
+∞ 7 0 +∞ +∞
4 8 7
+∞ −3 9 0 +∞
-3
7 −6 +∞ +∞ 7 0
3 2
9
Remarque 12.2. Attention à ne pas confondre la matrice d’adjacence dans les graphes pondérés et non pon-
dérés : les zéros dans la matrice d’adjacence d’un graphe non pondéré deviennent des +∞ dans la matrice d’un
graphe pondéré, sauf sur la diagonale où ils restent des zéros.
Définition 12.4. Pour s et t deux sommets dans le graphe, on appelle distance de s à t, notée δ(s, t), comme
Remarque 12.5. Pour s et t deux sommets du graphe, δ(s, t) peut prendre les valeurs +∞ et −∞ :
— si t n’est pas accessible depuis s, il n’existe pas de chemin entre s et t. L’ensemble définissant δ(s, t) étant vide,
on a bien δ(s, t) = +∞.
— si t est un sommet accessible depuis s et si, sur un chemin de s à t, il existe un circuit de poids strictement
négatif, le poids d’un chemin de s à t n’est pas borné inférieurement, car on peut construire des chemins de poids
arbitrairement petit en bouclant sur ce circuit. Ainsi, δ(s, t) = −∞.
2 1
2 s −∞ +∞
0
−4 −8 7
−∞ −∞ +∞
3
Figure 12.2 – Un exemple de graphe avec des circuits de poids strictement négatifs : on a fait figurer les distances à
la source s. Trois sommets sont accessibles et situés sur un circuit de poids strictement négatif. Les sommets de droite
sont également sur un tel circuit, mais non accessibles.
Démonstration. Considérons un chemin c entre s et t, de poids δ(s, t), et de longueur (nombre d’arcs) minimale parmi
les chemins de poids δ(s, t) reliant s à t. S’il existait deux sommets égaux sur le chemin, on obtiendrait un circuit.
Comme il n’y a pas de circuit de poids strictement négatif dans le graphe par hypothèse, ce circuit est de poids nul
(sinon on pourrait le supprimer pour obtenir un chemin de s à t de poids strictement inférieur à δ(s, t), ce qui est
exclu). Mais le supprimer mène alors à un chemin de même poids mais avec strictement moins d’arcs, ce qui est exclu
également. Donc c est composé de sommets distincts.
Définition 12.10. Supposons que le tableau (ds (t))t∈G soit une estimation des distances δ(s, t) (c’est-à-dire ds (t) ≥
δ(s, t) pour tout t). Relâcher l’arc (u, v) consiste à réaliser l’affectation ds [v] ← min (ds [v], ds [u] + ω(u, v)).
Lemme 12.11. (Mêmes notations) On a toujours ds [v] ≥ δ(s, v) après relâchement de l’arc (u, v).
Démonstration. Avant relâchement, on a δ(s, u) ≤ ds [u] par hypothèse. Ainsi, par inégalité triangulaire, δ(s, v) ≤
ds [u] + ω(u, v).
Proposition 12.12. Soit t un sommet accessible depuis s et c = (s0 = s, s1 , . . . , sk = t) un chemin de poids δ(s, t)
entre s et t. En partant d’un tableau (ds [u])u∈G quelconque tel que ds [u] ≥ δ(s, u) pour tout u, avec ds [s] = 0, si on
relâche successivement les arcs (s0 , s1 ), . . . , (sk−1 , sk ) dans cet ordre, alors ds [t] contient δ(s, t) à la fin du processus.
Démonstration. La proposition 12.7 (optimalité des sous-chemins) montre que pour tout i ∈ {0, . . . , k}, (s0 , s1 , . . . , si )
est un plus court chemin de s à si . Montrons par récurrence sur i qu’après relâchement de l’arc (si−1 , si ), ds [si ] contient
δ(s, si ) :
— i = 0 : ds [s] vaut 0, qui est bien δ(s, s).
— soit i > 1 et supposons la propriété démontrée au rang i − 1. Alors juste avant le relâchement de (si−1 , si ),
ds [si−1 ] contient δ(s, si−1 ). Comme δ(s, si ) = δ(s, si−1 ) + ω(si−1 , si ), on a d[si ] ≤ δ(s, si ) après relâchement de
l’arc (si−1 , si ). Or le lemme 12.11 prouve que ds [si ] était supérieur ou égal à δ(s, si ) avant relâchement. Donc la
propriété est vraie au rang i.
— Par principe de récurrence, elle est vraie pour tout i ∈ {0, . . . , k}, et en particulier ds [t] contient δ(s, t) à la fin
du processus.
Remarque 12.13. La propriété précédente reste vraie si on relâche d’autres arcs entre les relâchements des (si , si+1 ) :
en effet, relâcher un arc ne peut que faire baisser les ds [u], mais le lemme 12.11 assure que l’on ne descendra pas en
dessous de δ(s, u) !
Renvoyer d
Complexité. L’algorithme de Bellman-Ford est de complexité O(n(a + n)), puisqu’il relâche n − 1 fois tous les arcs
de G, et un relâchement de tous les arcs nécessitant de parcourir toutes les listes d’adjacence, il a un coût O(n + a).
Détection des circuits de poids total strictement négatif. Il est en fait facile de tester l’existence d’un circuit
de poids total strictement négatif accessible depuis s, via la propriété suivante.
Proposition 12.15. Dans l’algorithme de Bellman-Ford, effectuons un relâchement supplémentaire de tous les arcs
(boucle principale de 1 à n). Alors le tableau d est modifié pendant le dernier tour de boucle si et seulement si il existe
un circuit de poids total strictement négatif accessible depuis s.
Démonstration. Dans le cas où il n’y a pas de tel circuit, on a vu qu’après les n − 1 tours de la boucle principale, d[t]
contient δ(s, t) pour tout sommet t. Un relâchement d’arc ne peut donc diminuer aucun d[t]. Supposons maintenant
qu’il existe un circuit de poids total strictement négatif, mais qu’aucun d[u] ne soit modifié. Ceci signifie qu’avant
le dernier tour de boucle, on a d[u] ≤ d[j] + ω(j, u) pour tout arc (j, u). Considérons un circuit s0 , . . . , sk = s0 de
poids total strictement négatif, accessible depuis s. Alors avant le dernier tour de boucle, d[si+1 ] ≤ d[si ] + ω(si , si+1 )
pour tout i ∈ [[0, k − 1]]. On a de plus ds [si ] < +∞ pour tout i ∈ [[0, k − 1]] car chaque si est accessible, donc
d[si+1 ] − d[si ] ≤ ω(si , si+1 ). Ainsi en sommant ces inégalités, on obtient, comme s0 = sk :
k−1
X k−1
X
0= (d[si+1 ] − d[si ]) ≤ ω(si , si+1 )
i=0 i=0
Calcul effectif de plus courts chemins. Pour calculer effectivement des plus court chemins depuis s, il suffit
comme dans le parcours en largeur d’un graphe non pondéré d’ajouter un tableau de prédecesseurs π : dans la boucle
interne de l’algorithme, si d[u] > d[j] + ω(j, u) alors d[u] prend la valeur d[j] + ω(j, u) et π[u] prend la valeur j. À la
fin de l’algorithme, il suffit de remonter depuis un sommet accessible vers s en suivant le tableau des prédecesseurs
pour obtenir un plus court chemin, à l’envers.
Exemple. On considère le graphe de la figure 12.3, constitué de 5 sommets : la source s, ainsi que t, x, y, z. On
suppose que l’on relâche les arcs dans l’ordre lexicographique, excepté ceux de la forme (s, t) et (s, y) que l’on relâche
à la fin. Ainsi, l’ordre de relâchement des arcs est (t, x), (t, y), (t, z), (x, t), (y, x), (y, z), (z, x), (z, s), (s, t), (s, y).
Comme il y a 5 sommets, les arcs doivent être relâchés 4 fois. On indique dans chaque sommet a l’estimation d[a],
après un relâchement de tous les arcs, et en gras ceux qui ont été modifiés après une étape. Dans cet exemple, il est
bien nécessaire de relâcher 4 fois tous les arcs. Un relâchement supplémentaire ne change rien : il n’y a pas de circuit
de poids strictement négatif (néanmoins, s, y, x, t, z, s est de poids nul). Les arcs en gras représentent le tableau des
prédecesseurs.
On suppose pour l’algorithme de Dijkstra que les arcs ont un poids positif.
1. prononcer « Daïjkstra »
t 5 x t 5 x t 5 x
∞ ∞ 6 ∞ 6 4
6 -2 6 -2 6 -2
8 -3 8 -3 8 -3
s 0 7 s 0 7 s 0 7
-4 -4 -4
7 7 7
∞ ∞ 7 ∞ 7 2
2 9 2 9 2 9
y z y z y z
Tour i = 3 Tour i = 4
Figure 12.3 – Déroulement de l’algorithme de Bellman-Ford
Terminaison de l’algorithme. L’algorithme fait un parcours du graphe depuis le sommet s : on peut retrouver le
parcours générique du chapitre précédent en supprimant ce qui a trait au tableau d, et en remplaçant H par l’ensemble
des sommets déja vus. Ainsi, l’algorithme termine.
Proposition 12.17. Dans l’algorithme de Dijkstra, la propriété « tout sommet t de H vérifie d[t] = δ(s, t) » est un
invariant de la boucle tant que.
Démonstration. — la propriété est vraie avant la boucle, car l’ensemble H est vide.
— Pour montrer l’hérédité, il suffit de montrer qu’un sommet u de F vérifiant d[u] minimal vérifie en fait d[u] =
δ(s, u). Distinguons deux cas :
— si u = s (c’est le premier tour de boucle), on a d[s] = δ(s, s) = 0 ;
— sinon, considérons un plus court chemin de s à u, noté s u. Considérons sur ce chemin le premier sommet
y qui n’appartient pas à H (qui existe bien, car s ∈ H et u ∈ / H) et x son prédeceseur. Le chemin se
décompose en s x→y u (on peut avoir s = x et/ou y = u). Puisque x est dans H, δ(s, x) = d[x]
(hypothèse de récurrence) et lorsqu’on a considéré x dans la boucle, on a rajouté y à F s’il n’y était pas
déja et relaché l’arc x → y : ainsi d[y] ≤ δ(s, x) + ω(x, y) = δ(s, y), donc il y a en fait égalité. Le morceau
de s à y étant un plus court chemin et les poids positifs, on a δ(s, y) ≤ δ(s, u). Puisque d[u] minimal parmi
les sommets dans F , on a d[u] ≤ d[y]. Ainsi :
d[u] ≤ d[y] = δ(s, y) ≤ δ(s, u) ≤ d[u]
Il y a donc égalité partout, et en particulier δ(s, u) = d[u].
Remarque 12.18. Le fait que les arcs aient un poids positif est un ingrédient essentiel de la preuve précédente, pour
pouvoir affirmer que δ(s, y) ≤ δ(s, u). L’exemple minimaliste suivant montre un graphe pour lequel l’algorithme de
Dijkstra lancé sur le sommet s = 0 ne fonctionne pas : l’algorithme relâche successivement les arcs (0, 1), (1, 3), (0, 2)
, (2, 1). Il faudrait relâcher à nouveau l’arc (1, 3) pour que le tableau des distances soit correct.
2 1
0 1 3
4 −3
2
Complexité de l’algorithme. La complexité de l’algorithme dépend de la structure de données utilisée pour gérer
l’ensemble F du pseudo-code.
• Si on utilise simplement un tableau de booléens pour marquer les éléments de F , retirer l’élément de F vérifiant
d[u] minimal a un coût O(n). Cette action est effectuée au plus n fois pour un coût total O(n2 ). À part cela,
la complexité de l’algorithme est la même que celle d’un parcours en largeur classique : on parcourt au plus
une fois toutes les listes d’adjacence, pour un coût total O(a + n) = O(n2 ) car a = O(n2 ). Ainsi avec cette
implémentation l’algorithme de Dijkstra a un coût O(n2 ).
• Si on utilise une file de priorité min pour gérer F , implémentée avec un tas (min), chaque opération de file
de priorité a une complexité O(log n). Il faut effectuer une opération sur la file de priorité lorsqu’on retire le
minimum (affectation de u, donc au plus une fois par sommet) mais aussi lorsqu’on diminue d[v] (donc au plus
une fois par arc). Attention : il faut avoir implémenté l’opération de diminution de la clé d’un élément quelconque
de la file. Ici, avec les sommets supposés être dans [[0, n − 1]], il suffit d’utiliser un tableau pos dans lequel est
stocké la position de chaque nœud i dans le tableau associé au tas. On accède ainsi facilement à la position de
chaque nœud, qu’on peut diminuer en temps O(log n), en répercutant les permutations effectuées dans le tableau
pos. Ainsi la complexité totale est O((a + n) log n).
La méthode à choisir dépend du graphe : pour un graphe « dense » (avec a proche de n2 ), on aura intérêt à utiliser
un tableau pour éviter les trop nombreuses opérations de file de priorité, par contre si le graphe est plus « creux »
(a = o(n2 / log n)), on aura intérêt à utiliser une file de priorité.
Remarque 12.19. Une implémentation de la structure de file de priorité min où les opérations de diminution de clé
ont une complexité amortie constante existe (et a été en fait inventée historiquement pour l’algorithme de Dijkstra) :
les tas de Fibonacci. Avec cette implémentation, la complexité se réduit à O(a + n log n).
Calcul effectif de plus courts chemins. L’adaptation est la même que pour l’algorithme de Bellman-Ford : il
suffit d’utiliser un tableau de prédécesseurs.
Exemple. En figure 12.4 est représenté le déroulement de l’algorithme de Dijkstra sur un graphe à 5 sommets, dont
la source s. Les arcs en gras représentent l’évolution du tableau des prédecesseurs.
t x t x t x
∞
1 ∞
1 ∞
1
9 8 14
9 9 9
s 0 2 3 4 6 s 0 2 3 4 6 s 0 2 3 4 6
9 9 9
5 5 5
∞ ∞ 5 ∞ 5 7
7 2 7 2 7 2
y z y z y z
m−1
D1 = M et Dm = (dm
i,j )0≤i,j≤n−1 avec dm
i,j = min{di,j } ∪ {dm−1
i,k + ω(k, j) | k ∈ [[0, n − 1]]}
m−1
= min{di,k + ω(k, j) | k ∈ [[0, n − 1]]} car ωj,j = 0
Ainsi, si le graphe ne possède pas de circuit de poids strictement négatif, il suffit de calculer la matrice Dn−1 pour
obtenir tous les δ(i, j). En suivant la définition, le calcul de Dm à partir de Dm−1 se fait avec une complexité O(n3 ) : il
y a n2 coefficients, et le calcul de chaque dm
i,j se fait en complexité O(n). On en déduit donc un algorithme de complexité
O(n4 ) pour le calcul de Dn−1 . On peut en fait accélérer le processus en calquant la multiplication matricielle usuelle :
Proposition 12.21. (R ∪ {+∞}, min, +, +∞, 0) est un semi-anneau commutatif, c’est-à-dire que :
— (R ∪ {+∞}, min) est un monoïde commutatif, de neutre +∞ ;
— (R ∪ {+∞}, +) est également un monoïde commutatif, de neutre 0 ;
— + est distributive par rapport à min ;
— +∞ est absorbant pour +.
Démonstration. Tout s’écrit facilement, vérifions la distributivité de + sur min : a + min(b, c) = min(a + b, a + c).
L’associativité de min est aussi immédiate : min(a, min(b, c)) = min(a, b, c) = min(min(a, b), c).
La proposition précédente montre que l’on peut définir un produit matriciel associatif pour les matrices de Mn (R ∪
{+∞}). La loi de multiplication de deux telles matrices A = (ai,j )0≤i,j<n et B = (bi,j )0≤i,j<n est la suivante :
A ? B = C où C = (ci,j )0≤i,j<n avec ci,j = min{ai,k + bk,j | 0 ≤ k ≤ n − 1}. Le neutre pour ce produit est la matrice
avec des zéros sur la diagonale et des +∞ ailleurs. Avec ce produit, on a Di = M ? · · · ? M = M i . On peut donc
calculer Dn−1 = M n−1 par exponentiation rapide, avec une complexité O(n3 log n). En fait, il nous suffit d’avoir M k
pour k ≥ n − 1, ce que fournit l’algorithme suivant :
Détection d’un circuit de poids strictement négatif. Pour détecter un circuit de poids strictement négatif, on
peut procéder de manière similaire à l’algorithme de Bellman-Ford : s’il existe un tel circuit, alors il en existe un tel
que tous les sommets soient distincts, excepté le premier sommet qui coïncide avec le dernier, notons le si . On a alors
un chemin si si de poids strictement négatif, et de longueur au plus n. Ainsi, le coefficient diagonal de la matrice
M n en case (i, i) est strictement négatif. On peut donc légèrement modifier l’algorithme pour calculer M k avec k ≥ n
(condition k < n à la place de k < n − 1 dans la boucle), et vérifier s’il existe un coefficient diagonal strictement
négatif.
Calcul effectif de plus courts chemins. Il existe plusieurs méthodes pour retrouver les plus courts chemins dans
l’algorithme précédent, la plus économique en mémoire consiste là encore à stocker dans une matrice de liaison les
prédécesseurs dans des plus courts chemins entre deux sommets. On pose donc πi,j =le prédecesseur de j dans un plus
court chemin de i à j, s’il existe. Initialement, πi,j = i si (i, j) est un arc du graphe, et a une valeur arbitraire sinon.
Lors du calcul de A2 , si le coefficient en case (i, j) est abaissé (on a ai,j > ai,k + ak,j ), alors πi,j prend la valeur πk,j .
En fin d’algorithme, s’il existe un chemin de i à j pour i 6= j, alors πi,j contient le dernier sommet intermédiaire (ou i
si le plus court chemin est l’arc i → j). Le calcul de la matrice de liaison ne change pas la complexité en temps comme
en mémoire, et il est facile de calculer effetivement un plus court chemin entre i et j en remontant depuis j.
Exemple. Le graphe de la figure 12.5 est un graphe d’ordre 5, il suffit donc de faire deux multiplications pour obtenir
les poids des plus courts chemins (une troisième permet de s’assurer qu’il n’y a pas de circuit de poids strictement
négatif). Seuls les coefficients pertinents de la matrice Π = (πi,j )0≤i,j<5 sont indiqués. Une fois le calcul effectué, on a
par exemple qu’un plus court chemin entre les sommets 1 et 4 est de poids −1. Pour le calcul effectif d’un plus court
chemin, on voit que π1,4 = 0, π1,0 = 3 et π1,3 = 1, donc 1 → 3 → 0 → 4 convient.
Remarque 12.23. On peut se demander s’il est possible d’utiliser un algorithme de multiplication sous-cubique
(comme l’agorithme de Strassen, de complexité O(nlog2 (7) )) pour améliorer la complexité précédente. En fait, la réponse
est oui, mais pas tel quel car R ∪ {+∞} n’a qu’une structure de semi-anneau et l’algorithme de Strassen demande
d’effectuer des soustractions. Mais ces questions sortent très largement du cadre de ce cours !
Définition 12.24. Dans l’algorithme de Floyd-Warshall sur un graphe G = (V, E, ω), de matrice d’adjacence M ,
on pose pour tout k ∈ [[0, n]], Mk = (mki,j ) avec mki,j le poids minimal d’un chemin de i à j dont tous les sommets
intermédiaires (c’est-à-dire i et j exclus) sont dans [[0, k − 1]] (on a donc notamment M0 = M car les chemins sans
sommets intermédiaires sont simplement les arcs).
Ces matrices sont à valeurs dans R ∪ {±∞}, mais bien sûr la valeur −∞ ne se produit que dans le cas où il y a un
circuit de poids total strictement négatif. En excluant ce cas, la proposition suivante indique comment calculer M k+1
à partir de M k :
Proposition 12.25. En l’absence de circuit de poids strictement négatif dans le graphe, on a pour tout (i, j, k) ∈
[[0, n − 1]]3 : mk+1 k k k
i,j = min(mi,j , mi,k + mk,j ).
Démonstration. • S’il n’y a pas de chemin entre i et j ne passant que par des sommets de [[0, k]], mki,j = mk+1
i,j =
+∞, et l’un des deux mki,k ou mkk,j vaut aussi +∞ (car sinon on aurait un chemin entre i et k et un autre entre
k et j, dont la concaténation fournirait un chemin entre i et j).
−5 1
1 3
4
7
2 3 6
8
0 4
-4
0 3 8 ∞ −4 . 0 0 . 0
∞ 0 ∞ 1 7
. . . 1 1
Initialement : A=
∞ 4 0 ∞ ∞
Π=
. 2 . . .
2 ∞ −5 0 ∞ 3 . 3 . .
∞ ∞ ∞ 6 0 . . . 4 .
0 3 8 2 −4 . 0 0 4 0
3 0 −4 1 7
3 . 3 1 1
2
Première multiplication : A =
∞ 4 0 5 11
Π=
. 2 . 1 1
2 −1 −5 0 −2 3 2 3 . 0
8 ∞ 1 6 0 3 . 3 4 .
0 1 −3 2 −4 . 2 3 4 0
3 0 −4 1 −1
3 . 3 1 0
4
Deuxième multiplication : A =
7 4 0 5 3 Π=
3 2 . 1 0
2 −1 −5 0 −2 3 2 3 . 0
8 5 1 6 0 3 2 3 4 .
Figure 12.5 – Calcul de plus courts chemins dans un graphe, à l’aide de multiplications matricielles
• Sinon, un chemin de poids minimal entre i et j ne passant que par des sommets de [[0, k]] peut être supposé
ne passer qu’au plus une fois par k (il suffit de supprimer le circuit - nécessairement de poids nul - entre les
première et dernière occurences de k dans un chemin de poids minimal pour en obtenir un de même poids dans
lequel k apparaît au plus une fois). Si ce chemin ne passe pas par k, on a mk+1 k
i,j = mi,j , et sinon le chemin se
c c2
décompose en i 1 k j avec c1 et c2 des chemins dont les sommets intermédiaires sont dans [[0, k − 1]], ainsi
mk+1 k k
i,j = mi,k + mj,k .
On obtient ainsi un algorithme de complexité O(n3 ) pour le calcul de tous les δ(i, j). La remarque suivante indique
que l’on peut se contenter de mettre à jour une unique matrice :
Remarque 12.26. En l’abscence de circuit de poids total négatif, on a également, pour tout (i, j, k) ∈ [[0, n − 1]]3 :
mk+1 k k k k k+1 k k k k+1 k k+1 k+1
i,j = min(mi,j , mi,k + mk,j ) = min(mi,j , mi,k + mk,j ) = min(mi,j , mi,k + mk,j ) = min(mi,j , mi,k + mk,j ).
Détection des circuits de poids total négatif. S’il existe un circuit de poids total négatif, prenons en un
sans sommet en double excepté les extrémités. Soit i le sommet aux extrémités, on aura alors ai,i < 0 à la fin de
l’algorithme. Il suffit donc de tester l’existence d’un élément diagonal strictement négatif à la fin de l’algorithme pour
tester l’existence d’un circuit de poids total strictement négatif.
Renvoyer A
Calcul effectif des plus courts chemins. Parmi d’autres méthodes, on peut là encore calculer une matrice
de liaison, comme dans l’algorithme par multiplications matricielles. Lorsqu’on fait ai,j ← ai,k + ak,j , on effectue
parallèlement πi,j ← πk,j , la matrice Π = (πi,j ) étant initialisée comme dans l’algorithme précédent.
Exemple. On reprend le même exemple que précédemment, pour naturellement obtenir le même résultat.
Application : fermeture transitive d’un graphe. Une dérivation de l’algorithme de Floyd-Warshall permet de
répondre facilement au problème de l’accessibilité dans un graphe, et fournit l’algorithme de Warshall (historiquement
antérieur à l’algorithme de Floyd-Warshall...)
Définition 12.28. Soit G = (V, E) un graphe orienté, non pondéré. On appelle fermeture transitive de G le graphe
G̃ = (V, Ẽ), où pour u 6= v deux sommets de G̃, (u, v) appartient à Ẽ s’il existe un chemin de u à v dans G.
Renvoyer A
Remarque 12.30. Pour la résolution du problème (4) sur un graphe dense, la complexité asymptotique de l’algorithme
de Dijkstra utilisé n fois (en gérant la file de priorité avec un tableau) est la même que celle de l’algorithme de Floyd-
Warshall (O(n3 )). Néanmoins, la constante cachée dans le O est plus faible pour l’algorithme de Floyd-Warshall,
et celui-ci a le mérite de s’appliquer même s’il y a des arcs de poids négatifs. Pour un graphe dense, on préférera
l’algorithme de Floyd-Warshall pour calculer une solution au problème (4) !
−5 1
1 3
4
7
2 3 6
8
0 4
-4
0 3 8 ∞ −4 . 0 0 . 0
∞ 0 ∞ 1 7
. . . 1 1
M0 = M =
∞ 4 0 ∞ ∞
Π=
. 2 . . .
2 ∞ −5 0 ∞ 3 . 3 . .
∞ ∞ ∞ 6 0 . . . 4 .
0 3 8 ∞ −4 . 0 0 . 0
∞ 0 ∞ 1 7
. . . 1 1
M1 =
∞ 4 0 ∞ ∞
Π=
. 2 . . .
2 5 −5 0 −2 3 0 3 . 0
∞ ∞ ∞ 6 0 . . . 4 .
0 3 8 4 −4 . 0 0 1 0
∞ 0 ∞ 1 7
. . . 1 1
M2 =
∞ 4 0 5 11
Π=
. 2 . 1 1
2 5 −5 0 −2 3 0 3 . 0
∞ ∞ ∞ 6 0 . . . 4 .
0 3 8 4 −4 . 0 0 1 0
∞ 0 ∞ 1 7
. . . 1 1
M3 =
∞ 4 0 5 11
Π=
. 2 . 1 1
2 −1 −5 0 −2 3 2 3 . 0
∞ ∞ ∞ 6 0 . . . 4 .
0 3 −1 4 −4 . 0 3 1 0
3 0 −4 1 −1
3 . 3 1 0
M4 =
7 4 0 5 3 Π=
3 2 . 1 0
2 −1 −5 0 −2 3 2 3 . 0
8 5 1 6 0 3 2 3 4 .
0 1 −3 2 −4 . 2 3 4 0
3 0 −4 1 −1
3 . 3 1 0
M5 =
7 4 0 5 3 Π=
3 2 . 1 0
2 −1 −5 0 −2 3 2 3 . 0
8 5 1 6 0 3 2 3 4 .
Figure 12.6 – Calcul de plus courts chemins dans un graphe, à l’aide de l’algorithme de Floyd-Warshall
Proposition 12.32. En reprenant les notations de la définition précédente, il existe un graphe couvrant de G de poids
minimal qui est un arbre.
Démonstration. Soit G0 un graphe couvrant de poids minimal de G (ce graphe existe car il existe au moins un graphe
couvrant de G : G lui-même). En notant n = |V |, G0 possède au moins n − 1 arêtes car il est connexe. On peut de
plus supposer G0 minimal en nombre d’arêtes parmi les arbres couvrants de poids minimal. S’il existait un cycle dans
G0 , on pourrait retirer une arête du cycle sans perdre la connexité en diminuant le poids : c’est absurde. Donc G0 est
un arbre.
En pratique, si les valeurs de ω sont strictement positives, un graphe couvrant minimal est un arbre. S’il y a des
arêtes de poids nul, un graphe couvrant minimal peut ne pas être un arbre, mais il existe au moins un graphe couvrant
minimal qui est un arbre. L’algorithme 12.33 (algorithme de Prim), très proche de l’algorithme de Dijkstra, permet
de trouver un tel arbre.
Théorème 12.34. Avec E 0 l’ensemble renvoyé par l’algorithme de Prim, (V, E 0 ) est un arbre couvrant de poids
minimal.
Démonstration. L’algorithme réalise un parcours du graphe, qui atteindra tous les sommets car G est connexe : les
n sommets vont donc passer par F . L’algorithme ajoute à E 0 une arête pour chaque sommet excepté le sommet de
départ, ainsi on a à la fin de l’algorithme |E 0 | = n − 1, de plus (V, E 0 ) est connexe car tous les sommets sont raccordés
au sommet initial s. Donc (V, E 0 ) est un arbre. Notons s = v0 , v1 , . . . , vn−1 les sommets dans l’ordre de leur ajout à
H, et pour i ∈ {1, . . . , n − 1} notons ei l’arête ajoutée à E 0 , reliant vi au graphe induit par {vj | j < i}. Supposons
que (V, E 0 ) ne soit pas un arbre couvrant de poids minimal et considérons un arbre (V, A) couvrant de poids minimal,
et notons
i = max{j | e1 , . . . , ej ∈ A}
On peut sans perte de généralités supposer que (V, A) est un arbre couvrant minimal pour lequel i est maximal, et
on va aboutir à une absurdité. L’arête ei+1 , reliant vi+1 à un sommet vj (avec j ≤ i) n’est pas dans A. Travaillons
sur le graphe (V, A ∪ {ei+1 }) : ce graphe possède un cycle car (V, A) est un arbre. En considérant le cycle comme un
chemin bouclant de vj sur lui même, en terminant par l’arête ei+1 , notons v le premier sommet qui n’est pas dans
{v0 , . . . , vi }, et e l’arête le reliant au sommet précédent.
— ω(e) ≥ ω(ei+1 ), car sinon ei+1 n’aurait pas été rajoutée à E 0 . En effet, on aurait eu d[v] < d[vi+1 ] à ce moment
là ;
— ω(e) ≤ ω(ei+1 ), car sinon (V, A ∪ {ei+1 }\{e}) serait un arbre couvrant de poids strictement inférieur à celui de
(V, A).
Ainsi, e a le même poids que ei+1 , et (V, A ∪ {ei+1 }\{e}) est un arbre couvrant de même poids que (V, A) mais tel
que max{j | e1 , . . . , ej ∈ A ∪ {ei+1 }\{e}} > i, ce qui est absurde par hypothèse. Ainsi (V, E 0 ) est un arbre couvrant
de poids minimal.
Proposition 12.35. La complexité de l’algorithme précédent est en O(n2 ) avec une implémentation avec tableaux,
O(a log n) avec une file de priorité implémentée avec un tas binaire.
Démonstration. La complexité est exactement la même que dans l’algorithme de Dijkstra, avec ici n = O(a) car le
graphe est connexe.
Exemple. On termine par l’exemple du graphe suivant, où l’on part de s pour trouver un arbre couvrant minimal.
On a fait figurer dans chaque sommet u qui n’est pas dans H la valeur d[u], les arêtes en gras forment l’arbre couvrant
minimal.
t x t x t x
1 1 1
∞ ∞ 9 ∞ 3 9
9 9 9
s 0 3 4 s 3 4 s 3 4
9 9 9
5 5 5
∞ ∞ 5 7 2
7 2 7 2 7 2
y z y z y z
Figure 12.7 – Déroulement de l’algorithme de Prim : le poids minimal d’un arbre couvrant est 11
Chapitre 13
13.1 Introduction
Ce chapitre, et le suivant, traite de mots, et de langages : un langage est simplement un ensemble de mots. La
question qui va le plus nous intéresser est la suivante : un mot donné appartient-il à un langage donné ? Malgré sa
simplicité apparente, cette question est, en toutes généralités, difficile, il est même parfois impossible d’y répondre
algorithmiquement ! Néanmoins elle est dans certains cas faciles à traiter d’un point de vue algorithmique. Donnons
quelques exemples où cette question apparaît.
Reconnaissance de motifs. Dans le tronc commun d’informatique de première année, on s’est posé la question
de savoir si un mot s contenait un autre mot m. Cette question peut être décidée par un algorithme naïf en temps
O(|m| |s|), produit des tailles des deux mots. On peut reformuler la question ainsi : le mot s appartient-il au langage
des mots qui contiennent m ? On verra notamment une approche plus efficace que l’algorithme naïf dans le prochain
chapitre.
Analyse syntaxique. Lorsqu’on essaie d’exécuter un programme dans un langage de programmation comme Python
ou Caml, le compilateur réalise avant d’exécuter une instruction une analyse syntaxique : un exemple courant est l’oubli
d’une parenthèse qui se traduit par la sentence « Invalid syntax ». Vérifier que les parenthèses sont correctement
imbriquées est une traduction de : « le programme forme-t-il un mot bien parenthésé ? ». L’analyse syntaxique a
aussi pour but de vérifier par exemple qu’un mot réservé du langage (comme « else ») n’est pas utilisé comme une
variable. L’analyse syntaxique peut donc être vue comme la question d’appartenance du programme à l’ensemble des
programmes syntaxiquement corrects.
Analyse lexicale. Pour mener à bien l’analyse syntaxique décrite précédemment, le compilateur réalise au préalable
une analyse lexicale : il s’agit de découper le programme en morceaux : 123.54 doit être reconnu comme un flottant,
78 comme un entier, = comme l’opérateur d’affectation ou test d’égalité suivant le langage de programmation, etc...
Là aussi, les questions d’appartenance d’un mot à un langage (langage des entiers, langage des flottants, etc...) est
omniprésente.
Formulaires WEB et recherche dans des fichiers. Sur un site web où un utilisateur souhaite s’enregistrer, il faut
vérifier que ce qu’il entre au clavier dans les champs à renseigner a la forme voulue. On s’intéresse donc à l’appartenance
au langage des adresses mail, des dates, etc... Par exemple, ^[0-3][0-9]/(0[1-9]|1[0-2])/[1-2][0-9]{3}$ est une
expression régulière désignant les dates entre 1000 et 2999 (et un peu plus...). Ce genre d’expressions régulières est
également utilisée pour filtrer les lignes d’un fichier texte qui vérifient une certaine condition, la commande grep (Get
Regular ExPression), permet à l’aide de l’expression précédente de filtrer les lignes d’un fichier qui sont des dates.
Imagerie. Une image peut être vue comme un mot sur un certain alphabet. Une question récente a été la recon-
naissance de QR-code, qui se traduit par une question d’appartenance à un langage.
10ème problème de Hilbert. Ce problème énoncé en 1900 soulevait la question de l’existence d’un algorithme
capable de prendre en entrée une équation diophantienne (une équation algébrique à coefficients entiers) et de décider
si elle possédait ou non une solution rationnelle. Il a fallu attendre 1970 pour qu’un mathématicien russe, Youri
Matiiassevitch, montre l’inexistence d’un tel algorithme : il n’existe donc pas d’algorithme capable de prendre en
entrée une équation diophantienne, et décidant si cette équation appartient au langage des équations ayant des solutions
rationnelles.
Biologie. En génétique, on s’intéresse aux mots sur l’alphabet {A, C, T, G}. Des questions intéressantes (un peu
éloignées de la question initiale) émergent naturellement de ce contexte, comme l’existence de facteurs communs ou
de proximité entre deux individus.
Théorème 13.12. Deux mots x et y de Σ∗ commutent (pour la concaténation) si et seulement si ce sont deux
puissances d’un même mot, autrement dit il existe z ∈ Σ∗ et deux entiers i, j ≥ 0 tels que x = z i et y = z j .
Démonstration. La condition est suffisante : deux puissances d’un même mot commutent. Montrons qu’elle est néces-
saire, par récurrence sur |x| + |y| :
— si l’un des deux mots est vide, disons x = ε, alors x = y 0 et y = y 1 : la propriété est vérifiée.
— sinon, appliquons le lemme de Lévi à xy = yx. Si |x| ≤ |y|, il existe t tel que y = xt = tx. Comme x est non
vide, |x| + |t| = |y| < |x| + |y|. Donc par hypothèse de récurrence, x et t s’écrivent tous deux z i et z j , et donc
y = z i+j . Le raisonnement est symétrique si |x| > |y|.
— Par principe de récurrence, l’équivalence est démontrée.
En fait, cette manière de procéder est essentiellement unique, comme le montre la proposition qui suit.
Proposition 13.17. Soit m un mot de Dyck non vide. Alors m se décompose de manière unique en aubv, avec
u, v ∈ D.
Démonstration. • Existence. L’ensemble des préfixes non vides de m de valuation nulle est non vide, car il contient
m. Considérons p le plus petit élément de l’ensemble, et notons v le suffixe de m tel que m = pv. ν(v) =
ν(m) − ν(p) = 0, et pour v 0 un préfixe de v on a ν(v 0 ) = ν(pv 0 ) ≥ 0 car pv 0 est un préfixe de m ∈ D. Donc
v ∈ D. De plus, p étant non vide, sa première lettre est un a (sinon on aurait un préfixe de valuation strictement
négative). Sa dernière lettre est un b car sinon p s’écrirait ap0 a avec ν(ap0 ) = ν(p) − 1 < 0, ce qui n’est pas. Donc
p est de la forme aub. D’une part ν(u) = ν(p) = 0. De plus, un préfixe strict de p non vide étant de valuation
strictement positive par définition de p, les préfixes de u sont de valuation positive. Donc u ∈ D et l’existence
de la décomposition est démontrée.
• Unicité. Donnons nous deux décompositions m = aubv = au0 bv 0 . On peut supposer que u est préfixe de u0 . Si u
était préfixe strict de u0 , u0 aurait pour préfixe ub, ce qui est exclus car ν(ub) = −1. Donc u = u0 , et v = v 0 par
régularité.
Cette décomposition permet de dénombrer les mots de Dyck. Ceux-ci sont clairement tous de longueur paire, comptons-
les.
Lemme 13.18. Notons Cn le nombre de mots de Dyck de taille 2n, pour n ≥ 0. Alors (Cn )n∈N vérifie :
n−1
X
C0 = 1 et Cn = Ck Cn−1−k pour n ≥ 1
k=0
Démonstration. Immédiat.
1 2n
Théorème 13.19. Le nombre de mot de Dyck de taille 2n est Cn = n+1 n pour tout n ≥ 0.
Démonstration. Il est facile de vérifier le résultat par récurrence. Donnons une preuve combinatoire. On note Mi,j les
mots sur {a, b} ayant i fois la lettre a et j fois la lettre b, et Dn l’ensemble des mots de Dyck de Mn,n . Dénombrons
les mots de longueur 2n ≥ 2 ayant autant de a que de b mais n’étant pas de Dyck, c’est-à-dire Mn,n \Dn : pour un tel
mot m, il existe un préfixe de valuation −1, notons le plus petit p et considérons l’application :
Remarque 13.20. Les nombres (Cn ) sont les nombres de Catalan 1 . Ils interviennent très souvent en combinatoire,
et donc en informatique. Par exemple, ils dénombrent :
— les mots bien parenthésés ;
— les chemins de (0, 0) à (2n, 0) dans le demi-plan y ≥ 0, qui utilisent les deux déplacements de vecteurs (1, 1) et
(1, −1) ;
— les arbres binaires entiers à n nœuds internes ;
— les triangulations d’un polygone convexe à n + 2 côtés ;
— ...
13.3 Langages
13.3.1 Définition et cardinalité
Fixons un alphabet Σ. L’ensemble des mots (suites finies de lettres) sur l’alphabet Σ est noté Σ∗ .
— langage des mots contenant bracada mais pas abracadabra comme facteur (sur l’alpahabet usuel, par exemple) ;
— langage des mots contenant bracada mais pas abracadabra comme sous-mot ;
— ...
Démonstration. Σ∗ est en bijection avec N, donc l’ensemble de tous les langages avec P(N). Classiquement, un ensemble
n’est jamais en bijection avec l’ensemble de ses parties 2 , donc il y a une infinité non dénombrable de langages sur un
alphabet Σ.
Remarque 13.24. L’ensemble des algorithmes étant dénombrable (ils forment un langage !), pour la plupart des
langages il n’existe pas d’algorithme capable de prendre en entrée un mot quelconque et de décider s’il appartient ou
non au langage. Un langage pour lequel il existe un tel algorithme est dit récursif. Dans la suite, on va s’intéresser à
une toute petite partie des langages récursifs : les langages rationnels.
L} ⊂ L2 ).
— étoile de Kleene : on note L∗ = ∪n≥0 Ln , appelée l’étoile de Kleene du langage L, ensembe de mots obtenus par
concaténation de mots de L. On note aussi L+ = ∪+∞ n
n=1 L , qui ne contient ε que si L le contient. Ces notations
∗ +
sont cohérentes avec Σ et Σ .
Exemple 13.26. ((((ab) + c)a)∗ + (cb∗ )) + ε est rationnelle sur Σ = {a, b, c}.
Remarque 13.27. Une manière plus propre de les définir est d’utiliser des arbres binaires : les feuilles sont ∅, ε et
les a pour a ∈ Σ, les opérateurs +, · et ∗ sont associés à des nœuds internes, d’arité 2 pour + et ·, et d’arité 1 pour
∗. Par exemple l’expression précédente se représente comme :
+ ε
∗ .
. c ∗
+ a b
. c
a b
2. Si on suppose l’existence d’une sujection ϕ : E → P(E), on aboutit à une contradiction en considérant un antécédent de {ω ∈ E | ω ∈
/
ϕ(ω)}. C’est le théorème de Cantor-Bernstein. Plus simplement, P(N) est en bijection avec R, qui n’est pas dénombrable.
Définition 13.28. À une expression rationnelle e, on associe L(e), un langage de Σ∗ , défini également inductivement :
— L(∅) = ∅, L(ε) = {ε}, et L(a) = {a} pour tout a ∈ Σ ;
— pour e1 , e2 deux expressions rationnelles, L(e1 + e2 ) = L(e1 ) ∪ L(e2 ), L(e1 e2 ) = L(e1 ) · L(e2 ) et L(e∗1 ) = L(e1 )∗ .
Définition 13.29. Un langage est dit rationnel s’il est associé à une expression rationnelle.
Remarque 13.30. Lorsqu’on s’intéresse aux langages dénotés par des expressions rationnelles, on évite les parenthèses
qui alourdissent l’écriture d’une expression rationnelle. Par exemple, ((((ab) + c)a)∗ + (cb∗ )) + ε sera notée plus
simplement ((ab + c)a)∗ + cb∗ + ε.
Proposition 13.31. L’ensemble Rat(Σ) des langages rationnels sur Σ est la plus petite partie de Σ∗ contenant ∅, {ε},
les langages {a} pour a ∈ Σ et stable par union, concaténation, et étoile de Kleene.
Démonstration. Immédiat.
Exemple 13.32. — Les langages finis sont rationnels : en effet le langage contenant le seul mot m = m1 , . . . , mk
est obtenu comme L(m1 ) · · · L(mk ), et un langage fini L s’obtient comme l’union finie L = ∪m∈L {m}.
— Σ∗ est rationnel. Σ+ = ∪a∈Σ aΣ∗ est rationnel.
— L’ensemble des mots qui ont un mot m comme facteur est Σ∗ mΣ∗ , et est donc rationnel.
Remarque 13.33. Les deux expressions rationnelles (a + b)∗ et a(a + b)∗ + b(a + b)∗ + ε dénotent toutes deux le
langage {a, b}∗ . Il n’y a donc pas unicité de l’expression rationnelle dénotant un langage rationnel. On pourra faire le
parallèle avec la différence entre syntaxe et sémantique d’une expression logique.
Réglons tout de suite la question de savoir s’il existe un langage non rationnel 3 .
Démonstration. L’ensemble des expressions rationnelles sur Σ est dénombrable : en effet, pour chaque entier h ∈ N,
il existe un nombre fini (non nul) d’arbres de hauteur h représentant une expression rationnelle : l’ensemble des
expressions rationnelles est dénombrable 4 , ainsi Rat(Σ) est au plus dénombrable. De plus, si a ∈ Σ, Rat(Σ) contient
l’infinité de langages ({ap })p∈N , et est donc dénombrable.
Démonstration. Rat(Σ) est dénombrable (donc en bijection avec N), P(Σ∗ ) ne l’est pas, car il est en bijection avec
P(N).
Exemple 13.36. On verra plus tard que les langages constitués des mots de Dyck sur {a, b} ou encore {an bn | n ≥ 0}
ne sont pas rationnels.
Proposition 13.37. Soit e une expression rationnelle dénotant un langage L(e) 6= ∅. Alors il existe e0 sans ∅ telle
que L(e0 ) = L(e).
— si e s’écrit e1 e2 avec e1 , e2 deux expressions rationnelles, avec L(e) 6= ∅, alors L(e1 ) et L(e2 ) sont tous deux non
vides : on procède de même que précédemment, en écrivant L(e) = L(e01 e02 ).
— si e = e∗1 , alors :
— soit L(e1 ) = ∅, auquel cas L(e) = {ε} et e0 = ε convient.
— soit L(e1 ) 6= ∅, donc par induction il existe e01 sans ∅ telle que L(e1 ) = L(e01 ) et e0 = e0∗
1 est sans ∅ et dénote
L(e).
Par principe d’induction, la propriété est démontrée.
Proposition 13.38. Soit e une expression rationnelle dénotant un langage L(e) 6= ∅ et L(e) 6= {ε}. Alors il existe e0
sans ∅ ni ε telle que L(e) = L(e0 ) ou L(e) = L(e0 ) ∪ {ε}.
Démonstration. Là encore, la propriété se démontre par induction. D’après la proposition précédente, on peut supposer
que e est de la forme a ∈ Σ, ou de la forme e1 + e2 , e1 e2 , e∗1 , avec e1 et e2 sans ∅. Il n’y a rien à montrer pour a ∈ Σ,
dans les trois autres cas on distingue les cas L(ei ) = {ε} et L(ei ) 6= {ε}, auquel cas on se donne e0i tel que L(ei ) = L(e0i )
ou L(ei ) = L(e0i ) ∪ {ε}, et on construit e0 sans ε ni ∅ telle que L(e) soit égal à L(e0 ) ou L(e0 ) ∪ {ε}.
e1 + e2 e1 e2
e1 \e2 ε e02 e02 + ε e1 \e2 ε e02 e02 + ε
0
ε ε(exclus) e2 + ε e02 + ε ε ε(exclus) e02 e02 + ε
e01 e01 + ε e01 + e02 e1 + e02 + ε
0
e01 e01 e01 e02 e1 e2 + e01
0 0
e∗1
e1 ε e01 e01 + ε
e∗1 ε(exclus) e0∗
1 e0∗
1
Démonstration. Un élément de L non réduit à ε commence par une lettre de P (L) et termine par une lettre de S(L),
il est donc dans (P (L)Σ∗ ∩ Σ∗ S(L)). Aucun de ses facteurs de taille 2 n’est dans N (L), donc il n’appartient pas à
Σ∗ N (L)Σ∗ . D’où le résultat.
Exemple 13.42. Pour L = L(e) avec e = (ab)∗ c + bca + ε, on a : P (L) = {a, b, c}, S(L) = {a, c}, F (L) =
{ab, ba, bc, ca}, et N (L) = Σ2 \F (L). Le mot abca est dans (P (L)Σ∗ ∩ Σ∗ S(L))\Σ∗ N (L)Σ∗ mais pas dans L.
Définition 13.43. Un langage sur Σ∗ est dit local s’il y a égalité dans l’inclusion de la proposition précédente.
Exemple 13.44. Le langage L(e) de l’exemple précédent n’est donc pas local. Par contre, L((ab)∗ c + ε) l’est.
et L est local.
Remarque 13.46. — Dans la propriété précédente, il n’y a pas a priori égalité entre les inclusions comme P ⊂
P (L) : par exemple sur Σ = {a, b, c}, avec P = {a, b}, S = {a, c} et F = {ab, ba, ca}, on a P (L) = {a, b},
S(L) = {a} et F (L) = {ab, ba}. Les éléments « inutiles » ont été supprimés.
— La définition d’un langage local montre que ceux-ci sont en nombre fini.
a) Intersection
Démonstration. On considère deux langages L1 et L2 locaux. Si m 6= ε est dans L1 ∩ L2 , sa première lettre est dans
P1 ∩ P2 , ses facteurs sont dans F1 ∩ F2 , et sa dernière lettre est dans S1 ∩ S2 . La réciproque est immédiate, donc avec
N = Σ2 \(F1 ∩ F2 ).
L\{ε} = (P1 ∩ P2 )Σ∗ ∩ Σ∗ (S1 ∩ S2 )\Σ∗ N Σ∗
La propriété 13.45 montre que L est local.
Proposition 13.48. Si L1 et L2 sont deux langages locaux sur Σ1 et Σ2 avec Σ1 ∩ Σ2 = ∅, alors L = L1 ∪ L2 est
local sur Σ = Σ1 ∪ Σ2 .
Remarque 13.49. Si Σ1 ∩ Σ2 6= ∅, la propriété est fausse : par exemple {ab} et {bc} sont tous deux locaux mais pas
leur union, qui devrait contenir abc.
Proposition 13.50. Si L1 et L2 sont deux langages locaux sur Σ1 et Σ2 avec Σ1 ∩ Σ2 = ∅, alors L1 · L2 est local sur
Σ = Σ1 ∪ Σ2 .
Démonstration. Le tableau suivant donne les ensembles P (L), S(L) et F (L) avec L = L1 ·L2 en fonction des ensembles
caractéristiques de L1 et L2 , dans le cas où les deux langages sont non vides (sinon L est vide, et donc local) :
ε∈
/ L2 ε ∈ L2
P (L) = P1 P (L) = P1
ε∈
/ L1 S(L) = S2 S(L) = S1 ∪ S2
F (L) = F1 ∪ F2 ∪ S1 P2 F (L) = F1 ∪ F2 ∪ S1 P2
P (L) = P1 ∪ P2 P (L) = P1 ∪ P2
ε ∈ L1 S(L) = S2 S(L) = S1 ∪ S2
F (L) = F1 ∪ F2 ∪ S1 P2 F (L) = F1 ∪ F2 ∪ S1 P2
c) Étoile de Kleene
Proposition 13.52. Si L est local, alors L∗ est local.
Démonstration. Avec P , S, et F les ensembles caractéristiques de L, ceux de L∗ sont : P (L∗ ) = P , S(L∗ ) = S, et
F (L∗ ) = F ∪ S · P . Montrons que l’on a bien P (L∗ )Σ∗ ∪ Σ∗ S(L∗ )\Σ∗ N (L∗ )Σ∗ ⊂ L∗ \{ε} : Considérons un tel mot m,
et regardons ses facteurs qui sont dans S · P \F : ceux-ci induisent une factorisation de m en mots m1 m2 · · · mk , où la
dernière lettre de mi est dans S, la première lettre de mi dans P , et tous les facteurs de taille 2 de mi dans F : donc
mi ∈ L et m ∈ L∗ .
13.5 Implémentation
On implémente les expressions rationnelles représentant un langage non vide sur un alphabet Σ à l’aide du type
erat suivant :
type 'a erat =
Eps
| S of 'a
| Plus of 'a erat * 'a erat
| Conc of 'a erat * 'a erat
| Etoile of 'a erat
;;
On a pris un type polymorphe, de sorte que l’alphabet peut-être représenté par des entiers ou des chaînes de
caractères. Voici par exemple l’expression rationnelle (a + b)∗ + ac∗ :
let e=Plus (Etoile (Plus (S "a", S "b")), Conc (S "a", Etoile (S "c"))) ;;
Voici un exemple d’une fonction testant si le langage associé à une expression rationnelle contient ε.
Décrivons une fonction qui calcule simultanément les ensembles P (L), S(L) et F (L) d’un langage L donné par une
expression rationnelle. On utilise :
— des listes d’éléments de type 'a pour les ensembles P (L) et S(L) ;
— une liste de couples d’éléments de type 'a pour F (L).
Pour mener à bien le calcul, on doit :
— faire l’union (sans doublons) de deux listes ;
— calculer le produit cartésien de deux listes.
4
Une estimation de la complexité est O(|Σ| |e|), avec |e| la taille de l’expression e (pouvant être définie comme
le nombre de nœuds de l’arbre associé) : les tailles des listes pi et si sont au plus Σ, et celles des listes fi au plus
Σ2 . L’opération la plus coûteuse est l’union de listes encodant les ensembles de facteurs de taille 2 des langages, de
complexité O(Σ4 ). Ensuite, on fait appel à ces fonctions sur les listes un nombre borné de fois par nœud de l’arbre
associé, d’où la complexité.
On pourrait en fait réduire la complexité à O( Σ2 |e|) en utilisant des tableaux à la place de listes, et si l’alphabet
est gros, on peut encore réduire cette complexité en quelque chose de linéaire en la taille du résultat (multiplié par le
nombre de nœuds) à l’aide de tables de hachage.
A · L ∪ B = A+ B ∪ A0 B = (A+ ∪ {ε}) · B = A∗ B = L
Remarque 13.57. Si ε ∈ A, la solution n’est pas unique (sauf si A∗ B = Σ∗ ) : par exemple Σ∗ est alors solution de
l’équation.
Exemple 13.58. Appliquons le lemme d’Arden pour déterminer une expression rationnelle du langage des mots sur
{a, b} contenant un nombre pair de a, noté L0 . On introduit également L1 , ensemble des mots contenant un nombre
impair de a. En discutant suivant la première lettre d’un mot non réduit à ε appartenant à l’un des deux langages, on
obtient le système suivant :
L0 = {ε} ∪ aL1 ∪ bL0
L1 = aL0 ∪ bL1
Appliquons le lemme d’Arden à la deuxième équation : comme ε ∈ / {b}, on obtient L1 = b∗ aL0 . En reportant dans la
première équation, on obtient l’équation :
L0 = {ε} ∪ (b + ab∗ a)L0
/ (b + ab∗ a). On conclut donc que L0 = (b + ab∗ a)∗ .
Là encore, ε ∈
La méthode précédente se généralise pour déterminer les solutions à un système de n équations à n inconnues, via la
preuve constructive du théorème suivant.
Théorème 13.59. Soit (Ai,j )0≤i,j≤n−1 un n2 -uplet de langages sur Σ ne contenant pas ε, et soit (B0 , . . . , Bn−1 ) un
n-uplet de langages quelconques. Alors le système suivant, d’inconnues (L0 , . . . , Ln−1 ) possède une unique solution.
n−1
[
Li = Ai,j Lj ∪ Bi pour 0 ≤ i ≤ n − 1
j=0
De plus, si les langages (Ai,j ) et (Bi ) sont tous rationnels, les composantes (Li ) de la solution sont également ration-
nelles.
Démonstration. Pour n = 1, le théorème est équivalent au lemme d’Arden, car A∗ B est rationnel si A et B le sont.
Montrons le résultat pour un entier n ≥ 2 par récurrence. La dernière équation s’écrit
n−2
Ln−1 = An−1,n−1 Ln−1 ∪ ∪j=0 An−1,j Lj ∪ Bn−1
avec A0i,j = Ai,j ∪ Ai,n−1 A∗n−1,n−1 An−1,j pour 0 ≤ i, j ≤ n − 2 et Bi0 = Bi ∪ Ai,n−1 A∗n−1,n−1 Bn−1 pour 0 ≤ i ≤ n − 2.
Comme ε n’appartient ni à Ai,j ni à Ai,n−1 , il n’appartient pas à A0i,j pour tout 0 ≤ i, j ≤ n − 2. Ainsi, par hypothèse
de récurrence, ce système possède une unique solution (L0 , . . . , Ln−2 ), qu’on complète avec Ln−1 pour obtenir une
solution au système initial. De plus, si tous les paramètres de l’équation initiale sont des langages rationnels,les langages
(Li )0≤i≤n−2 le sont aussi (hypothèse de récurrence), ainsi que Ln−1 = A∗n−1,n−1 · ∪n−2 j=0 An−1,j Lj ∪ Bn−1 .
Chapitre 14
Automates
14.1 Introduction
Essentiellement, un automate est un programme, qui prend en entrée un mot (donné lettre par lettre) sur un
alphabet Σ fixé, et qui l’accepte ou le rejette. Une contrainte forte est que la quantité de mémoire utilisable par
l’automate est finie.
Le langage des mots reconnus par l’automate est appelé le langage reconnu par l’automate. Un langage sur Σ est
dit reconnaissable s’il existe un automate qui le reconnaît.
Concrètement, la finitude de la mémoire utilisable par l’automate se traduit par un nombre fini d’états : on démarre
à un état initial, et la lecture des lettres du mot fait changer d’état, en suivant des transitions. Le mot est accepté si
après sa lecture, on se retrouve dans un état final (ou acceptant). Visuellement, on représente un automate comme
un graphe : les sommets du graphe forment les états de l’automate, ses arcs (étiquetées par des lettres de Σ) les
transitions.
Exemple 14.1. Voici par exemple un automate à deux états reconnaissant le langage des mots sur {a, b} ayant un
nombre pair de a. On démarre à l’état q0 (état initial) et on lit le mot lettre par lettre en suivant les transitions. Lire
un a fait changer d’état, lire un b nous fait rester dans le même état. L’état initial est aussi le seul état final dans cet
exemple.
b b
a
q0 q1
a
Le fait que la mémoire disponible soit finie impose des contraintes fortes sur l’ensemble des langages reconnaissables
par un automate. Par exemple, on montrera que {an bn | n ∈ N} n’est pas reconnaissable : il faudrait pouvoir compter
jusqu’à n pour reconnaître an bn , ce qui nécessite 1 + blog2 (n)c bits. Or, (log2 (n))n∈N∗ n’est pas bornée.
On représente l’automate comme un graphe (avec des boucles et des multi-arêtes) dont les sommets sont les états,
l’état initial est indiqué par une flêche entrante et les états finaux par des doubles cercles 1 Si δ(q, a) = q 0 , il y a un
arc q → q 0 étiqueté par a.
q0 a q1 a q2
a, b
L’alphabet est Σ = {a, b}, l’ensemble des états est {q0 , q1 , q2 }, l’état initial q0 , l’ensemble des états finaux est {q0 , q2 },
et la fonction de transition est décrite ci-dessous :
Q\Σ a b
q0 q1 blocage
q1 q2 q1
q2 q2 q2
Le lecteur se convaincra facilement que le langage reconnu est celui des mots commençant par a et contenant au moins
un autre a, auquel on ajoute le mot vide ε.
Définition 14.5. La fonction de transition δ s’étend en une fonction partielle δ ∗ : Q × Σ∗ → Q par récurrence : on
pose δ ∗ (q, ε) = q pour tout q ∈ Q, et ensuite, pour tout (q, a, m) ∈ Q × Σ × Σ∗ :
— δ ∗ (q, am) est un blocage si δ(q, a) en est un ou si δ ∗ (δ(q, a), m) en est un ;
— dans le cas contraire, δ ∗ (q, am) = δ ∗ (δ(q, a), m).
Par exemple, pour l’automate précédent, δ ∗ (q0 , baab) est un blocage, et δ ∗ (q0 , abb) = q1 .
Définition 14.6 (Langage reconnu par un automate). Pour A = (Σ, Q, q0 , F, δ) un AF D, on appelle langage reconnu
par l’automate le langage :
L(A) = {m ∈ Σ∗ | δ ∗ (q0 , m) ∈ Q}
On verra que les langages reconnaissables sur un alphabet Σ coïncident avec les langages rationnels. Seule l’impli-
cation langage rationnel ⇒ langage reconnaissable est au programme. Il est déja facile de voir que tout langage sur
un alphabet Σ ne peut être reconnaissable : il n’y a, à renommage des états près, qu’un nombre fini d’automates à n
états pour un entier n donné. L’ensemble des langages reconnaissables est donc dénombrable, contrairement à P(Σ∗ ).
On va maintenant définir quelques propriétés que peuvent avoir les automates, et montrer qu’on peut toujours
supposer qu’un automate donné possède une telle propriété, du point de vue du langage reconnu. Ceci permet de
simplifier certaines preuves et constructions.
Automate complet
Définition 14.9. Un automate est dit complet lorsque la fonction de transition δ est sans blocage.
Ceci implique que la fonction de transition étendue δ ∗ est également sans blocage, donc définie sur Q × Σ∗ .
Démonstration. Soit A = (Σ, Q, q0 , F, δ) un automate. Notons q∞ un élément qui n’est pas dans Q. Alors l’automate
A0 = (Σ, Q ∪ {q∞ }, q0 , F, δ 0 ), où δ 0 est définie par :
0 q∞ si q = q∞ ou si δ(q, a) est un blocage de A;
δ (q, a) =
δ(q, a) sinon
est un automate complet équivalent à A. En effet, on a simplement rajouté un état « puits » q∞ où l’on fait aboutir
toutes les transitions non définies de A. Comme q∞ n’est pas final dans A0 , le langage reconnu est le même.
Exemple 14.11. Voici un automate complet équivalent à l’automate précédent :
b
q0 a q1 a q2
b
a, b
q∞
a, b
Automate standard
Définition 14.12. Un automate est dit standard si aucune transition n’aboutit à l’état initial.
Proposition 14.13. Un automate est équivalent à un automate standard.
Démonstration. Pour construire un automate standard reconnaissant le même langage que A = (Σ, Q, q0 , F, δ), il suffit
/ Q. Considérons l’automate A0 = (Σ, Q ∪ {q̃0 }, q̃0 , F̃ , δ 0 ), où δ 0 est définie comme
d’introduire un nouvel état initial q̃0 ∈
suit :
δ(q, a) si cette transition est définie et q 6= q̃0 .
δ 0 (q, a) = δ(q0 , a) si cette transition est définie et q = q̃0 .
blocage sinon.
L’ensemble F̃ vaut F si q0 n’est pas un état final, et F ∪ {q̃0 } sinon (avec seulement F on perdrait le mot vide du
langage reconnu par l’automate). Alors A0 reconnaît le même langage que A : en effet, pour tout mot m de taille au
moins 1, on a δ ∗ (q̃0 , m) = δ ∗ (q0 , m) dans A0 , et ε est reconnu par A si et seulement si il l’est par A0 .
Exemple 14.14. Voici un automate standard reconnaissant le langage des mots sur {a, b} ayant un nombre pair de
a:
b b
a
b q0 q1
q̃0
a
a
Remarque 14.15. « Standardiser » un automate complet ne change pas son caractère complet : tout automate est
donc équivalent à un automate standard complet.
Automate émondé
Définition 14.16. Soit A = (Σ, Q, q0 , F, δ) un AFD. Un état q de A est dit :
— accessible, s’il existe un mot m de Σ∗ tel que δ ∗ (q0 , m) = q ;
— co-accessible, s’il existe un mot m de Σ∗ tel que δ ∗ (q, m) ∈ F ;
Une conséquence immédiate de la définition est que l’état initial est accessible, et que tout état final est co-accessible.
Les états non accessibles ou non co-accessibles sont un peu inutiles du point de vue du langage reconnu : on ne peut
pas y accéder ou bien n’accéder à aucun état final depuis eux.
Définition 14.17. Un automate est dit émondé si tout état de l’automate est à la fois accessible et co-accessible.
« Presque » tous les automates sont équivalents à des automates émondés, comme on va le voir avec la proposition
qui suit. Avant de l’énoncé, montrons un lemme.
Lemme 14.18. Soit A = (Σ, Q, q0 , F, δ) un AFD, tel que L(A) 6= ∅ (le langage reconnu par A est non vide). Alors q0
est co-accessible et l’automate possède un état final accessible.
Démonstration. Soit m ∈ L(A). Comme δ ∗ (q0 , m) ∈ F , l’état q0 est co-accessible. De même, l’état δ ∗ (q0 , m) est un
état final accessible.
Proposition 14.19. Un automate reconnaissant un langage non vide est équivalent à un automate émondé.
Démonstration. Soit A = (Σ, Q, q0 , F, δ) un automate reconnaissant un langage non vide. On considère Q0 l’ensemble
des états de Q à la fois accessibles et co-accessibles (non vide car il contient q0 ), et δ 0 la fonction de transition de
Q0 × Σ → Q0 définie par :
si δ(q, a) ∈ Q0
δ(q, a)
δ 0 (q, a) =
blocage sinon.
On pose également F 0 = Q0 ∩ F . Alors l’automate A0 = (Σ, Q0 , q0 , F 0 , δ 0 ) reconnaît le même langage que A :
• L(A0 ) ⊂ L(A) est évident car on a supprimé des états et des transitions de A pour obtenir A0 .
• L(A) ⊂ L(A0 ). Soit m ∈ L(A). Alors q = δ ∗ (q0 , m) ∈ F ∩ Q0 = F 0 . Tous les états du chemin de q0 à q étiqueté
par les lettres de m sont à la fois accessibles et co-accessibles, donc sont dans Q0 , et les transitions étiquetées
par les lettres de m depuis q0 ne sont pas des blocages, ainsi δ 0∗ (q0 , m) ∈ F 0 et m ∈ L(A0 ).
Remarque 14.20. Émonder un automate standard ne change pas cette propriété, donc un automate reconnaissant
un langage non vide est équivalent à un automate émondé standard.
Par contre, il n’est en général pas possible de construire un automate émondé complet équivalent à un automate
donné, comme le montre la proposition suivante.
Proposition 14.21. Soit A = (Σ, Q, q0 , F, δ) un automate complet émondé. Alors Pref (L(A)) (le langage constitué
des préfixes des mots de L(A)) est égal à Σ∗ tout entier.
Démonstration. Soit m ∈ Σ∗ . Notons q = δ ∗ (q0 , m). L’état q est co-accessible, donc il existe un mot m0 tel que
δ ∗ (q, m0 ) ∈ F . Ainsi mm0 est un mot de L(A) dont m est un préfixe.
Autrement dit, toutes les transitions étiquetées par une lettre donnée aboutissent à un même état.
Quitte à supprimer les états non accessibles d’un automate local, on voit que ceux-ci possèdent au plus 1 + |Σ|
états : un état par lettre, plus éventuellement l’état initial. Comme leur nom l’indique, les automates locaux sont liés
aux langages locaux.
Théorème 14.23. Un langage local est reconnu par un automate local standard.
Démonstration. Soit L un langage local, donné par le triplet (P, S, F ) ⊆ Σ × Σ × Σ2 de ses premières et dernières
lettres et de ses facteurs de longueur 2. On considère l’automate local standard A = (Σ, Q, q0 , F, δ) défini par :
— Q = Σ ∪ {q0 } (il y a donc |Σ| + 1 états).
— F = S si ε 6∈ L, F = S ∪ {q0 } sinon.
— δ(q0 , x) = x pour tout x ∈ P , δ(q0 , x) est un blocage pour tout x 6∈ P .
— Pour tout (x, y) ∈ Σ × Σ, δ(x, y) = y si xy ∈ F , et δ(x, y) est un blocage sinon.
Alors L(A) = L. En, effet, ε ∈ L(A) si et seulement si ε ∈ L. De plus, soit m 6= ε un mot non vide du langage L.
Les transitions indexées par les lettres de m depuis q0 mènent à un état final, donc m appartient au langage L(A).
Réciproquement, si m est un mot non vide de L(A), alors m est bien dans le langage local associé au triplet (P, S, F ),
car sa première lettre est dans P , sa dernière dans S et ses facteurs de taille 2 dans F .
Remarque 14.24. La réciproque est vraie : le langage reconnu par un automate local (standard) est local.
Corollaire 14.25. Les langages dénotés par des expressions linéaires sont reconnaissables.
La preuve précédente fournit directement un moyen de construire un automate local reconnaissant le langage dénoté
par une expression rationnelle linéaire.
Exemple 14.26. Soit e = ε + a(b∗ + c), construisons un automate reconnaissant L = L(e) :
— P (L) = {a} ;
— S(L) = {a, b, c}
— F (L) = {ab, ac, bb}
Comme ε ∈ L, l’automate suivant reconnaît L :
b
qb
b
q0 a qa
c
qc
q0 a q1 a q2
a, b
Notons pour i ∈ {0, 1, 2} le langage Li = {m ∈ Σ∗ | δ ∗ (qi , m) ∈ F }. En distinguant suivant la première lettre d’un
mot non vide de Li , on obtient les relations :
L0 = {ε} ∪ aL1
L1 = bL1 + aL2
L2 = {ε} ∪ (a + b)L2
Le lemme d’Arden s’applique : on trouve successivement : L2 = Σ∗ , puis L1 = b∗ aΣ∗ , et enfin L0 = {ε} ∪ ab∗ aΣ∗ . Ce
dernier langage n’est autre que L(A), dénoté par l’expression rationnelle ε + ab∗ a(a + b)∗ .
Passons à la preuve du théorème.
Démonstration du théorème 14.27. Soit L un langage reconnaissable. D’après une propriété précédente, L est reconnu
par un automate A = (Σ, Q, q0 , F, δ) déterministe complet. Numérotons q1 , . . . , qn−1 les états de Q différents de q0 ,
et définissons
Li = {m ∈ Σ∗ | δ ∗ (qi , m) ∈ F } pour tout 0 ≤ i ≤ n − 1. On s’intéresse à L0 = L(A). Notons
{ε} si qi ∈ F,
1ε,qi = . Alors on obtient pour tout 0 ≤ i ≤ n − 1 l’équation :
∅ sinon.
n−1
[
(Ei ) Li = Bi ∪ Ai,j Lj avec Ai,j = {a ∈ Σ | δ(qi , a) = qj } et Bi = 1ε,qi
j=0
Les Ai,j sont rationnels (car finis), et ne contiennent pas ε. Les Bi sont également rationnels. Il s’ensuit que d’après
le dernier théorème du chapitre précédent, ce système d’équations en les Li admet une unique solution dont les
composantes sont rationnelles. En particulier L0 = L est rationnel.
14.3.1 Définitions
Définition 14.29. Un automate fini non déterministe (AFND) est un quintuplet (Σ, Q, I, F, δ) où :
— Σ est l’alphabet associé à l’automate ;
— Q est l’ensemble des états de l’automate ;
— I est l’ensemble des états initiaux ;
— F est l’ensemble des états finaux ;
— δ est la fonction de transition, c’est une application de Q × Σ → P(Q)
La différence est essentiellement celle citée plus haut : il peut y avoir plusieurs états initiaux, et la fonction de
transition n’est plus à valeurs dans Q mais dans l’ensemble des parties de Q, c’est à dire que plusieurs transitions
depuis un état donné peuvent être étiquetées par la même lettre. La notion de « blocage » vue précédemment est
légèrement modifiée : δ n’étant plus une fonction partielle, δ(q, a) est toujours défini mais peut valoir ∅.
La fonction de transition s’étend par récurrence de la même manière que précédemment : on pose δ ∗ (q, ε) = {q}
pour tout q ∈ Q, et ensuite δ ∗ (q, am) = ∪q0 ∈δ(q,a) δ ∗ (q 0 , m) pour tout (a, m) ∈ Σ × Σ∗ . Autrement dit, δ ∗ (q, m) est
l’ensemble des états que l’on peut atteindre depuis q par un chemin étiqueté par m.
a, b a, b
q0 a q1 b q2 a q3
Définition 14.31. Un mot m est accepté par un AFND A = (Σ, Q, I, F, δ) s’il existe q0 ∈ I tel que δ ∗ (q0 , m) ∩ F 6= ∅.
Autrement dit, un mot m est accepté par l’automate s’il existe un chemin étiqueté par m depuis l’un des états initiaux
vers un état final.
Remarque 14.32. Les notions d’automate standard et d’automate émondé s’étendent au cas non déterministe.
a, b a, b
a b b
0 1 2 3
Comme il y a 4 états dans cet automate, le déterminisé en possède 24 = 16. On va se contenter de construire uniquement
les états accessibles depuis I = {0}. Voici le résultat :
a
a b
b
a b b a b
{0} {0, 1} {0, 2} {0, 3} {0, 1, 3} {0, 2, 3}
a a
Il y a 10 sommets non accessibles dans le déterminisé, qu’il est inutile de construire. Remarquons aussi que dans
l’automate précédent, les trois derniers états pourraient être jumelés en un seul, pour obtenir un automate plus petit
reconnaissant le même langage.
L’automate déterminisé peut avoir a priori un nombre d’états exponentiel en le nombre d’états de l’automate non
déterministe initial, même après émondage. Voici un exemple.
Exemple 14.35. Soit n ≥ 2. Le langage des mots ayant un a à la n-ème position en partant de la fin, c’est à dire
L(Σ∗ a(a + b)n−1 ). Il est reconnu par l’automate non déterministe suivant, à n + 1 états :
a, b
a a, b a, b a, b a, b a, b
q0 q1 q2 q3 ··· qn−1 qn
Montrons qu’un automate déterministe A = (Σ, Q, q0 , F, δ) reconnaissant L possède au moins 2n états. Remarquons
que l’ensemble des préfixes des mots de L est Σ∗ , donc la fonction δ n’a pas de blocage, ainsi la fonction suivante :
ϕ: Σn −→ Q
m 7−→ δ ∗ (q0 , m)
est bien définie. Montrons qu’elle est injective. Supposons l’existence de deux mots distincts u = u0 u1 · · · un−1 et
v = v0 v1 · · · vn−1 de Σn tels que ϕ(u) = ϕ(v). Notons i le premier indice où u et v diffèrent, on peut supposer ui = a
et vi = b. Considérons un mot quelconque s de taille i. Alors us ∈ L, et vs ∈ / L. Or
Remarque 14.36. Le langage précédent n’est autre que le langage miroir de L((a + b)n−1 aΣ∗ ), mots de {a, b}∗ ayant
un a en n-ème position. Cet automate est reconnu par l’automate déterministe suivant :
a, b
a, b a, b a, b a, b a, b a
q0 q1 q2 q3 ··· qn−1 qn
Rappel. Si L(e) 6= ∅, il existe une expression e0 sans ε ni ∅ telle que L(e) = L(e0 ) ou L(e) = L(e0 ) ∪ {ε}. On peut
donc supposer e sans ε ni ∅ : en effet, si L(e) = ∅, il n’est pas dur de construire un automate reconnaissant L(e), et
si L(e) = L(e0 ) ∪ {ε}, il suffit de rajouter l’état initial 2 d’un automate reconnaissant L(e0 ) dans les états finaux pour
obtenir un automate reconnaissant L(e).
Exemple 14.38. Considérons e = a(ba + b)∗ b sur l’alphabet {a, b}. Sa linéarisation est e0 = c1 (c2 c3 + c4 )∗ c5 sur
l’alphabet Σ0 = {ci | 1 ≤ i ≤ 5}.
Proposition 14.39. Soit e un expression sans ∅ ni ε sur un alphabet Σ, et e0 sa linéarisée sur Σ0 . Notons ϕ(ci ) la
lettre de Σ associée à ci , pour tout 1 ≤ i ≤ k. On peut prolonger ϕ en un morphisme de monoïde de Σ0∗ dans Σ∗ .
Alors ϕ(L(e0 )) = L(e).
2. ou l’un des états initiaux dans le cas non déterministe.
Démonstration. La preuve se fait par induction sur e. Elle est évidente si e est réduite à un seul caractère. Traitons
les autres cas :
— e = f + g. Notons f 0 et g 0 les linéarisés (sur des alphabets disjoints) de f et g. Par hypothèse d’induction, on a
ϕ(L(f 0 )) = L(f ) et ϕ(L(g 0 )) = ϕ(L(g)). Ainsi
.
— e = f g. Notons également f 0 et g 0 les linéarisés de f et g. Par hypothèse d’induction, on a ϕ(L(f 0 )) = L(f ) et
ϕ(L(g 0 )) = ϕ(L(g)). Ainsi
Proposition 14.40. Soit e une expression rationnelle sans ∅ ni ε sur un alphabet Σ, e0 sa linéarisée sur Σ0 =
{c1 , . . . , ck }. On note toujours ϕ(ci ) la lettre de Σ associée à une lettre de Σ0 . Considérons A0 un automate local
c
standard reconnaissant L(e0 ). Alors l’automate A obtenu en remplaçant chaque transition de A de la forme q →i q 0 par
ϕ(ci )
la transition q → q 0 est un automate (non déterminisite a priori) reconnaissant L(e).
Démonstration. • Soit m un mot de L(e) = ϕ(L(e0 )). Alors il existe un mot m0 ∈ L(e0 ) tel que m = ϕ(m0 ). Par
construction, il existe dans A0 un chemin étiqueté par les lettres de m0 depuis l’état initial jusqu’à un état final.
Par suite, il existe dans A un chemin depuis l’état initial jusqu’à un état final étiqueté par les lettres de m, donc
m ∈ L(A).
• Réciproquement, soit m un mot de L(A). Il existe donc dans A un chemin étiqueté par les lettres de m depuis
l’état initial jusqu’à un état final. En repassant par A0 , on construit un mot m0 ∈ Σ0∗ tel que ϕ(m0 ) = m et m0
reconnu par A0 . Ainsi m0 ∈ L(e0 ), donc m ∈ ϕ(L(e0 )) = L(e).
Définition 14.41. L’automate de Glushkov d’une expression rationnelle e est le déterminisé de l’automate reconnais-
sant L(e0 ) obtenu précédemment.
Exemple
Prenons un exemple complet. Considérons l’expression e = a(ba + b)∗ b sur l’alphabet Σ = {a, b}.
Calcul des ensembles caractéristiques. On calcule les ensembles P (L(e0 )), S(L(e0 )), F (L(e0 )) :
Calcul d’un automate local standard reconnaissant L(e0 ). Une fois les ensembles caractéristiques calculés, on
produit l’automate reconnaissant L(e0 ).
c1 c2
q0 q1 q2
c2
c5
c2 c3
c4
q5 q4 q3
c5 c4
c4
c5
Suppression des marques de la linéarisation. Il suffit de rétablir les lettres de {a, b} à la place des lettres de
Σ0 pour obtenir un automate non déterministe reconnaissant L(e).
q0 a q1 b q2
b
b
b a
b
q5 q4 q3
b b
b
Déterminisation. Il reste alors à déterminiser l’automate obtenu. En ne construisant que les états accessibles, on
obtient ici un automate ayant un nombre particulièrement faible d’états, mais c’est du à la simplicité de l’expression
choisie.
a
q0 a q1 b q2 , q4 , q5 q3
b
Complexité.
Soit e une expression rationnelle. On note `(e) la taille de l’expression e (nombre de nœuds de l’arbre associé).
On a vu dans le chapitre précédent la complexité de calcul des ensembles caractéristiques. Comme Σ0 est de cardinal
O(`(e)), on obtient une complexité O(`(e)3 ) (en faisant usage de tableaux et non de listes) pour le calcul des en-
sembles caractéristiques de e0 , et donc de l’automate non déterministe reconnaissant e. Malheureusement, le coût de
la déterminisation peut être exponentiel en `(e), à cause du nombre d’états que peut avoir l’automate déterminisé.
Complémentation
Proposition 14.44. Soit L un langage rationnel sur l’alphabet Σ. Alors Σ∗ \L est rationnel.
Démonstration. Soit A = (Σ, Q, q0 , F, δ) un automate déterministe complet reconnaissant L. Alors Ā = (Σ, Q, q0 , Q\F, δ)
reconnaît Σ∗ \L, car pour tout mot m ∈ Σ∗ , on a δ ∗ (q0 , m) ∈
/ F ⇔ δ ∗ (q0 , m) ∈ Q\F .
Remarque 14.45. Dans la preuve précédente, il est essentiel de supposer l’automate complet, sinon les mots induisant
un blocage dans A en induisent aussi un dans Ā.
Intersection
Proposition 14.46. Soit L1 , L2 deux langages rationnels sur l’alphabet Σ. Alors L1 ∩ L2 est rationnel.
Démonstration. On se donne Ai = (Σ, Qi , q0i , Fi , δi ) un automate déterministe reconnaissant Li , pour i ∈ {1, 2}. On
va considérer l’automate produit de A1 et A2 , c’est à dire : A = (Σ, Q1 × Q2 , q0 = (q01 , q02 ), F = F1 × F2 , δ), où δ est
définie par :
(δ1 (q1 , a), δ2 (q2 , a)) si ni l’un ni l’autre n’est un blocage
δ((q1 , q2 ), a) =
blocage sinon.
Alors L(A) = L(A1 ) ∩ L(A2 ). En effet
δ ∗ (q0 , m) ∈ F ⇐⇒ δ1∗ (q01 , m) ∈ F1 et δ2∗ (q02 , m) ∈ F2
Remarque 14.47. L’automate produit consiste essentiellement à exécuter simultanément m sur les deux automates.
q0 q0 q0 a q1
On se donne maintenant deux langages réguliers L1 et L2 dont on suppose connaître deux automates finis dé-
terminites A1 = (Σ, Q1 , q01 , F1 , δ1 ) et A2 = (Σ, Q2 , q02 , F2 , δ2 ) les reconnaissant. On suppose que ceux-ci sont à états
disjoints, c’est-à-dire que Q1 ∩ Q2 = ∅ (ce qui peut se faire aisément à l’aide d’un renommage).
Il est immédiat de voir que le langage reconnu par A est L1 ∪ L2 . Virtuellement, pour tester l’appartenace de m à
L1 ∪L2 , on exécute simultanément les deux automates sur m, et si on atteint un état final dans un des deux automates,
c’est que le mot appartient au langage.
Remarque 14.48. Une variante de l’automate produit donnerait un automate déterministe reconnaissant L.
Automate reconnaissant L1 L2 . On considère que l’automate choisi pour représenter L2 est standard, c’est-à-dire
qu’il n’y a pas de transition vers l’état initial q02 de A2 . On obtient un automate A reconnaissant L1 L2 en supprimant
a a
cet état initial et en remplaçant les transitions q02 → q par une transition q1 → q pour chaque q1 accepteur de A1 . Les
états terminaux de l’automate obtenu sont ceux de F2 si ε ∈ / L(A2 ) (équivalent à q02 non terminal dans A2 ) et ceux de
F1 ∪ F2 sinon. On obtient ainsi un automate non déterministe reconnaissant L1 L2 .
Remarque 14.49. Dans cette construction, l’état q02 n’est plus accessible et peut être supprimé.
Automate reconnaissant L∗1 On va rajouter des transitions à A1 pour qu’il reconnaisse L∗1 . On suppose encore
a a
que A1 est standard, d’état initial q01 . Pour toute transition q01 → q, de A, on rajoute la transition qf → q, pour
tout qf ∈ F (si cette transition n’est pas déja présente). Si nécessaire, on ajoute également q01 à l’ensemble des états
acceptants F . On obtient alors un automate non déterministe reconnaissant L∗1 .
Théorème 14.50. Si L est un langage rationnel sur Σ, alors Pref(L), Suff(L), Fact(L), SM(L) et t L sont rationnels.
Démonstration. La preuve utilise encore l’équivalence entre langage rationnel et langage reconnaissable. On suppose
L non vide (sinon il n’y a rien à montrer) et on se donne un automate déterministe émondé A = (Q, Σ, q0 , F, δ)
reconnaissant L. Alors :
— (Q, Σ, q0 , Q, δ) est un automate déterministe reconnaissant Pref(L) : le fait que l’automate soit émondé est
essentiel, ensuite ce n’est pas difficile à vérifier.
— (Q, Σ, Q, F, δ 0 ) avec δ 0 la fonction de transition similaire à δ mais en version non déterministe, est un automate
non déterministe reconnaissant Suff(L).
— (Q, Σ, Q, Q, δ 0 ) est un automate non déterministe reconnaissant Fact(L).
— on laise au lecteur le soin de rajouter des transitions à l’automate précédent pour en obtenir un qui reconnaît
SM(L).
— (Q, Σ, F, {q0 }, t δ) est un automate non déterministe reconnaissant t L, avec t δ définie par :
t
δ(q, a) = {q 0 ∈ Q | δ(q 0 , a) = q}
En clair : on a inversé état initial et états terminaux, et changé le sens des transitions.
Lemme 14.51 (Lemme de l’étoile). Soit L un langage rationnel. Alors il existe un entier N , tel que tout mot m de
longueur supérieur ou égale à N de L s’écrive sous la forme m = uvw, avec :
Démonstration. On utilise l’équivalence entre le langage rationnel et reconnaissable. Soit A un automate déterministe
reconnaissant L, notons N son nombre d’états. considérons un mot m de L de longueur au moins N , et considérons
m0 , . . . , mN −1 ses N premières lettres. Considérons q0 l’état initial, et qi+1 = δ(qi , mi ) pour i ∈ {0, . . . , N − 1}.
On obtient une séquence de N + 1 états, donc deux d’entre eux sont égaux, disons qi et qj avec i < j. On pose
alors u = m0 m1 · · · mi−1 le préfixe de longueur i de m, v = mi · · · mj−1 le facteur de longueur j − i qui suit, et
enfin w = mj . . . mn−1 le suffixe de m tel que m = uvw. Alors par construction, |v| ≥ 1 et |uv| ≤ N . De plus,
δ(q0 , uvw) ∈ F . Or δ(q0 , u) = δ(q0 , uv) = qi = qj Il s’ensuit par récurrence immédiate que δ(q0 , uv k ) = qi pour tout
k ≥ 0, et δ(q0 , uv k w) ∈ F , donc uv k w ∈ L.
Remarque 14.52. La preuve du lemme, qui s’appelle aussi lemme de pompage ou lemme de gonflement 6 , se mémorise
bien avec l’image suivante :
q0 u qi w qf
Remarque 14.53. Il existe des langages non rationnels qui vérifient le lemme de l’étoile, qui n’est donc pas une
condition suffisante (voir feuille d’exercices). On peut donner des versions plus fortes où on « pompe » sur n’importe
quel facteur de longueur N .
Automate reconnaissant L(Σ∗ mΣ∗ ). Notons m0 , . . . , mn−1 les lettres du mot m. Alors l’automate suivant (non
déterministe) reconnaît L(Σ∗ mΣ∗ ).
Σ∗ Σ∗
m0 m1 m2 m3 mn−2 mn−1
q0 q1 q2 q3 ··· qn−1 qn
Complexité. Une fois l’automate déterministe calculé, l’exécution de l’automate sur s se fait en temps O(|s|),
indépendamment de la taille de Σ si les transitions sont implémentées par une table de hachage 7 . Par contre, la
construction de l’automate déterminisite a a priori une complexité exponentielle en |m|. Ce n’est en général pas
génant car m est petit dans la pratique, mais un peu décevant quand même.
b a a, b
a
a a b
ε a aa aab aaba aabab
b a
b
Démonstration. On va montrer par récurrence sur |u| que la propriété suivante est vraie : « Pour p ∈ P (m) et u ∈ Σ∗ ,
si pu ne contient pas m comme facteur, alors δ ∗ (p, u) = s(pu) ».
— la propriété est claire pour u = ε, car s(p) = p ;
— supposons u 6= ε, u s’écrit alors va avec v ∈ Σ∗ et a ∈ Σ. Par hypothèse de récurrence, δ ∗ (p, v) = s(pv). Puisque
pu ne contient pas m comme facteur, s(pv) 6= m. On a donc δ ∗ (p, u) = δ(s(pv), a) = s(s(pv)a). Il reste à montrer
que s(s(pv)a) = s(pva) = s(pu) :
— s(s(pv)a) est un suffixe de pva qui est préfixe de m, donc s(s(pv)a) est de taille inférieure à s(pva) = s(pu) ;
— s(pu) est le plus long suffixe de pu qui est préfixe de m. Si s(pu) = ε, alors s(s(pv)a) = ε et il n’y a rien à
montrer. Sinon, la dernière lettre de s(pu) est a, donc s(pu) s’écrit wa, où w est un suffixe de pv, tel que
wa préfixe de m. Or w est également un préfixe de m, suffixe de pv, donc de taille inférieure à s(pv).
Ainsi, s(s(pv)a) = s(pu) et l’hérédité est démontrée.
— Par principe de récurrence, on a bien δ ∗ (p, u) = s(pu) pour tout p ∈ P (m) et u ∈ Σ∗ , tel que pu ne contient pas
m comme facteur.
7. Complexité constante amortie seulement.
Il reste à conclure la preuve à partir de cette propriété. La démonstration précédente s’étend au cas où m est suffixe de
pu sans être facteur de pu à une autre position (on a donc δ(p, u) = m). Comme l’état m est un puits de l’automate,
on montre ainsi que si m est facteur de u, alors δ(ε, u) = m. Réciproquement, si m n’est pas facteur de u, alors
δ(ε, u) = s(u) 6= m. Cette discussion montre que le langage reconnu par l’automate est exactement Σ∗ mΣ∗ .
Remarque 14.57. L’algorithme KMP (voir TP) permet en fait de construire un simple tableau contenant les tailles
des bords maximaux des préfixes de m, ce qui encode en fait l’automate sous une forme plus compacte. Mais l’idée
est exactement celle ci-dessus. On en déduit un algorithme de complexité O(|m| + |u|) permettant de tester si m est
facteur de u. De plus une fois l’automate (ou de manière équivalente, le tableau des bords maximaux) construit, on
peut tester si m est facteur d’un mot quelconque en temps linéaire en la taille de ce mot : le prétraitement n’est effectué
qu’une fois.
Chapitre 15
15.1 Introduction
Au chapitre 1, on a décrit les structures abstraites au programme : piles, files, files de priorité et dictionnaires. Le
long du présent ouvrage, on s’est attaché à donner une ou plusieurs implémentations de ces structures. Évidemment,
elles sont déja présentes en Ocaml : le but du chapitre est simplement de donner les différentes modules et fonctions
associées.
15.2 Piles
Pile se traduit par Stack en anglais : c’est le nom du module en Ocaml.
15.3 Files
Une file est appelée queue en anglais. Là-encore, c’est le nom du module. Les fonctions sont assez semblables à
celles sur les piles.
— [Link] de type 'a Queue.t -> 'a supprime et renvoie l’élément en tête de file, si celle-ci est non vide.
[Link] est un synonyme ;
L’exception levée si on essaie de défiler une file vide est [Link].
15.4 Dictionnaires
Les dictionnaires en Ocaml sont implémentés via des tables de hachage, on trouvera donc dans le module Hashtbl
les fonctions adéquates. Les tables de hachage en Ocaml on une petite spécificité par rapport aux dictionnaire abstraits
décrits dans le chapitre 2 : il est possible d’associer plusieurs valeurs à une clé donnée. Lorsqu’on essaie d’ajouter le
couple (k, e) à la table et que k est déja présente (disons que k est associé à e0 ), alors (k, e) masque (k, e0 ). Si on
supprime l’entrée de clé k, alors seul (k, e) sera supprimé, et le dictionnaire associe alors k à e0 . On ne donne que
quelques fonctions ici, il y en a d’autres (comme clear et copy...)
— [Link] de type int -> ('a, 'b) Hashtbl.t, crée et renvoie un dictionnaire vide. L’entier passé en
paramètre est une estimation du nombre d’entrées qu’il y aura dans la table, mais un appel avec paramètre 0
est tout à fait possible.
— [Link] donne le nombre d’entrées dans la table (le nombre de clés hachées).
— [Link] de type ('a, 'b) Hashtbl.t -> 'a -> bool teste s’il existe un couple de clé donnée dans la
table.
— [Link] de type ('a, 'b) Hashtbl.t -> 'a -> 'b -> unit. [Link] h x y ajoute le couple (x, y)
au dictionnaire, et masque l’éventuelle valeur déja associée à la clé x.
— [Link] de type ('a, 'b) Hashtbl.t -> 'a -> 'b renvoie l’élément associé à la clé passée en paramètre
si la clé est dans la table, où lève l’exception Not_found sinon.
— [Link] de type ('a, 'b) Hashtbl.t -> 'a -> unit supprime l’élément de clé passée en paramètre
(le premier s’il y en a plusieurs), ne fait rien s’il n’y en a pas.
— [Link] de type ('a, 'b) Hashtbl.t -> 'a -> 'b -> unit. [Link] h x y remplace l’élé-
ment courant (le premier s’il y en a plusieurs) associé à la clé x par y. Similaire à remove suivi de add.
module ResizableArray = (
(* une structure de tableau redimensionnable *)
struct
type 'a t = {mutable nb: int ; mutable cp: int ; mutable elem: 'a array}
let create () = {nb = 0; cp = 0; elem = [| |]}
let is_empty a = [Link] = 0
let length a = [Link]
let get a i = [Link].(i)
module PrioQueue = (
(* file de priorite min *)
struct
type 'a t = {tr: (int * 'a) ResizableArray.t ; pos: ('a, int) Hashtbl.t}
let create () = {tr = [Link] () ; pos = [Link] 0}
let is_empty f=ResizableArray.is_empty [Link]
let fg i = 2*i+1 and fd i = 2*i+2 and pere i = (i-1)/2
let echanger t i j h=
(* t tableau [Link], h la table de hachage [Link] *)
(* echange les elements d'indice i et j de t et met a jour les positions dans la table de hachage *)
let a=[Link] t i in [Link] t i ([Link] t j) ;
[Link] t j a ;
[Link] h (snd ([Link] t i)) i ;
[Link] h (snd ([Link] t j)) j
let monter t i h =
(* t: le tableau redimensionnable [Link], h la table de hachage *)
(* les elements sont compares suivant l'ordre lexicographique, donc sur la priorite *)
let j=ref i in
while !j>0 && [Link] t !j < [Link] t (pere !j) do
echanger t !j (pere !j) h;
j:=pere !j
done
let rec descendre t i h =
let n=[Link] t in
(* t: le tableau elem dans le tableau redimensionnable [Link], h la table de hachage *)
(* n le nombre d'elements dans la fp (donc interessant dans t) *)
let imax=ref i in
if fg i < n && [Link] t (fg i) < [Link] t i then imax := fg i ;
if fd i < n && [Link] t (fd i) < [Link] t !imax then imax :=fd i ;
if !imax <> i then begin
echanger t i !imax h;
descendre t !imax h
end
exception PrioQueue_is_empty
exception AlreadyMemberInPrioQueue
exception NotMemberInPrioQueue
let add f x p =
(* ajoute avec priorite p l'element x. x ne doit pas etre deja present *)
if [Link] [Link] x then raise AlreadyMemberInPrioQueue
else begin
[Link] [Link] (p,x) ;
[Link] [Link] x ([Link] [Link] - 1) ;
monter [Link] ([Link] [Link] - 1) [Link]
end
let remove_prio f =
if is_empty f then raise PrioQueue_is_empty else
begin
let n=[Link] [Link] in
echanger [Link] 0 (n-1) [Link] ;
let _,x=[Link] [Link] in
[Link] [Link] x ;
if n>1 then descendre [Link] 0 [Link] ;
x
end
let change_prio f x p=
(* on modifie la priorite de l'élément x (en general on baisse l'entier, c'est à dire que *)
(* l'élément doit remonter dans le tableau, mais l'inverse est possible) *)
if not ([Link] [Link] x) then raise NotMemberInPrioQueue ;
let i=[Link] [Link] x in
let pprev = fst ([Link] [Link] i) in
[Link] [Link] i (p,x) ;
if pprev > p then monter [Link] i [Link] else descendre [Link] i [Link]
let mem f x=[Link] [Link] x
end : PRIOQUEUE_SIG)
;;
Les fonctions de file de priorité disponibles sont les suivantes (précisées dans le module signature). Les priorités sont
des entiers dans cette implémentation.
— create : unit -> 'a PrioQueue.t
— is_empty : 'a PrioQueue.t -> bool
— add : 'a PrioQueue.t -> 'a -> int -> unit
— remove_prio : 'a PrioQueue.t -> 'a
— change_prio : 'a PrioQueue.t -> 'a -> int -> unit
— mem : 'a PrioQueue.t -> 'a -> bool
On a aussi défini les exceptions suivantes :
— exception PrioQueue_is_empty
— exception AlreadyMemberInPrioQueue
— exception NotMemberInPrioQueue
Entre struct et end se trouve les déclarations de types et de fonctions. On appelera ces fonctions en dehors du module
par nom_du_module.nom_de_la_fonction. A priori, tous les types et fonctions définis dans le module seront alors
accessibles en dehors du module. Or, lorsqu’on implémente une structure abstraite par exemple, on préfère masquer
à l’utilisateur le mécanisme interne des fonctions, et même le type manipulé. C’est là qu’interviennent les modules de
signature :
Pour les deux modules implémentés plus haut, vous voyez que certaines fonctions ont été choisies pour figurer dans
le module de signature, mais pas toutes. De même le type n’est pas décrit explicitement. Par exemple pour la file de
priorité (implémentée comme un couple tableau redimensionnable, table de hachage), cette description ne figure pas
dans la signature. Pour préciser les signatures révélées, il suffit de déclarer le module comme on l’a fait :
Voici un exemple :
# let f = [Link] () ;;
val f : '_a PrioQueue.t = <abstr>
# [Link] f 81 10 ;; (* ajout de 81 avec priorite 10 *)
- : unit = ()
# f ;;
- : int PrioQueue.t = <abstr>
# [Link] ;;
Error: Unbound record field tr
Le type PrioQueue.t est considéré comme abstrait, en particulier les champs tr et pos utilisés dans sa définition ne
sont pas accessibles, car n’apparaissent pas dans le module de signature.