0% ont trouvé ce document utile (0 vote)
20 vues187 pages

Poly Caml

Ce document est un polycopié du cours d'option informatique pour les élèves de première et deuxième année au lycée Masséna, structuré en chapitres couvrant divers aspects du langage OCaml et de l'informatique théorique. Il aborde des sujets tels que les structures de données, les algorithmes, la programmation dynamique, les graphes, et les automates, tout en intégrant des éléments du programme officiel et des extensions basées sur les besoins des élèves. Le dernier chapitre présente les modules usuels en OCaml, consolidant les connaissances acquises au cours des deux années.

Transféré par

pioverne123
Copyright
© © All Rights Reserved
Nous prenons très au sérieux les droits relatifs au contenu. Si vous pensez qu’il s’agit de votre contenu, signalez une atteinte au droit d’auteur ici.
Formats disponibles
Téléchargez aux formats PDF, TXT ou lisez en ligne sur Scribd
0% ont trouvé ce document utile (0 vote)
20 vues187 pages

Poly Caml

Ce document est un polycopié du cours d'option informatique pour les élèves de première et deuxième année au lycée Masséna, structuré en chapitres couvrant divers aspects du langage OCaml et de l'informatique théorique. Il aborde des sujets tels que les structures de données, les algorithmes, la programmation dynamique, les graphes, et les automates, tout en intégrant des éléments du programme officiel et des extensions basées sur les besoins des élèves. Le dernier chapitre présente les modules usuels en OCaml, consolidant les connaissances acquises au cours des deux années.

Transféré par

pioverne123
Copyright
© © All Rights Reserved
Nous prenons très au sérieux les droits relatifs au contenu. Si vous pensez qu’il s’agit de votre contenu, signalez une atteinte au droit d’auteur ici.
Formats disponibles
Téléchargez aux formats PDF, TXT ou lisez en ligne sur Scribd

Cours d’Option Informatique

Jules Svartz
Lycée Masséna
Lycée Masséna

Svartz Page 2/187


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 !

Svartz Page 3/187


Lycée Masséna

— 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.

Svartz Page 4/187


TABLE DES MATIÈRES Lycée Masséna

Table des matières

I Programme de première année 11

0 Introduction au langage OCaml 13


0.1 Caml : un langage fonctionnel (permettant la programmation impérative) . . . . . . . . . . . . . . . . 13
0.2 Un langage fortement typé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
0.3 Types simples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
0.3.1 Type « rien » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
0.3.2 Entier . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
0.3.3 Flottants . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
0.3.4 Booléens . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
0.4 Déclaration, déclaration locale, déclaration simultanée, références . . . . . . . . . . . . . . . . . . . . . 15
0.4.1 Déclaration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
0.4.2 Déclaration locale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
0.4.3 Déclaration simultanée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
0.4.4 Références . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
0.5 Quelques types plus complexes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
0.5.1 Tuples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
0.5.2 Tableaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
0.5.3 Chaînes de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
0.6 Fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
0.6.1 Quelques exemples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
0.6.2 Fonctions à un seul argument. Appel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
0.6.3 Curryfication des fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
0.6.4 Création de fonctions curryfiées . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
0.6.5 Création de fonctions non curryfiées . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
0.7 Conditions et boucles en Caml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
0.7.1 Expressions conditionnelles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
0.7.2 Séquence d’instructions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
0.7.3 Boucles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
0.8 Filtrages . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
0.8.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
0.8.2 Règles du filtrage sur motif . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
0.9 Types enregistrement et types somme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
0.9.1 Types enregistrement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
0.9.2 Types somme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
0.10 Exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28

1 Structure de données usuelles 31


1.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
1.2 Piles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
1.3 Files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
1.4 Files de priorité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
1.5 Dictionnaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33

Svartz Page 5/187


TABLE DES MATIÈRES Lycée Masséna

2 Programmation impérative en Caml 35


2.1 Analyse d’algorithmes : terminaison, correction, complexité . . . . . . . . . . . . . . . . . . . . . . . . 35
2.1.1 Terminaison . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
2.1.2 Correction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
2.1.3 Complexité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
2.1.4 Un exemple important : l’exponentiation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
2.2 Les tableaux en Caml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
2.2.1 Quelques exemples de fonctions basiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
2.2.2 Recherche dichotomique dans un tableau trié . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
2.2.3 Exemples : les algorithmes de tri . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
2.3 Implémentation d’une pile et d’une file dans un tableau . . . . . . . . . . . . . . . . . . . . . . . . . . 41
2.3.1 Piles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
2.3.2 Files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43

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

4 Analyse des fonctions récursives 59


4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.2 Terminaison . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.3 Correction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
4.4 Complexité des fonctions récursives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
4.4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
4.4.2 Premiers résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
4.4.3 Récurrences « Diviser pour régner » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64

5 Algorithmes « Diviser pour régner » 67


5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
5.2 Tri fusion : analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
5.2.1 Rappel : le code du tri . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
5.2.2 Analyse des fonctions auxiliaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
5.2.3 Analyse de la fonction de tri . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
5.3 Algorithmes de multiplication rapide : polynômes et matrices . . . . . . . . . . . . . . . . . . . . . . . 68
5.3.1 Multiplication rapide de polynômes : algorithme de Karatsuba . . . . . . . . . . . . . . . . . . 69
5.3.2 Algorithme de Strassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
5.4 Calcul de la paire de points les plus proches dans un nuage de points . . . . . . . . . . . . . . . . . . . 72
5.4.1 Approche naïve . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
5.4.2 Approche « diviser pour régner » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72

Svartz Page 6/187


TABLE DES MATIÈRES Lycée Masséna

6 Introduction à la programmation dynamique 75


6.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
6.2 Un exemple complet : chemin de poids maximal dans une matrice . . . . . . . . . . . . . . . . . . . . 76
6.2.1 Le problème . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
6.2.2 Recherche exhaustive ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
6.2.3 Solutions aux sous-problèmes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
6.2.4 Une relation récursive pour le poids maximal d’un chemin . . . . . . . . . . . . . . . . . . . . . 76
6.2.5 Un calcul itératif des pi,j . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
6.2.6 Détermination d’une solution au problème initial . . . . . . . . . . . . . . . . . . . . . . . . . . 77
6.3 Principes de la programmation dynamique, et variantes . . . . . . . . . . . . . . . . . . . . . . . . . . 78
6.3.1 La démarche d’une résolution de problème par programmation dynamique . . . . . . . . . . . . 78
6.3.2 Une parenthèse sur les problèmes de combinatoire . . . . . . . . . . . . . . . . . . . . . . . . . 78
6.3.3 Algorithmes « glouton » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
6.4 Deux autres exemples de résolution par programmation dynamique . . . . . . . . . . . . . . . . . . . . 80
6.4.1 Le problème de la sous séquence commune . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
6.4.2 Plus grand carré de zéros dans une matrice binaire . . . . . . . . . . . . . . . . . . . . . . . . . 82

7 Introduction aux arbres 83


7.1 Les arbres comme objets mathématiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
7.1.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
7.1.2 Un peu de dénombrement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
7.2 Les arbres en Caml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
7.2.1 Arbres généraux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
7.2.2 Arbres binaires entiers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
7.2.3 Arbres binaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
7.3 Parcours d’arbres binaires entiers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
7.3.1 Parcours en profondeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
7.3.2 Parcours en largeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89

II Programme de deuxième année 91


8 Structures à l’aide d’arbres : file de priorité et dictionnaire 93
8.1 Rappel sur les structures abstraites . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
8.2 Tas et file de priorité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
8.2.1 Une file de priorité à l’aide d’une liste ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
8.2.2 La structure de tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
8.2.3 Opérations sur un tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95
8.2.4 Stockage d’un arbre binaire complet à gauche dans un tableau . . . . . . . . . . . . . . . . . . 96
8.2.5 Implémentation de la structure de file de priorité max dans un tableau . . . . . . . . . . . . . . 97
8.2.6 Complexité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
8.2.7 Intermède : le tri par tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
8.2.8 Modification d’une clé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
8.3 Arbres binaires de recherche, Arbres AVL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
8.3.1 Implémentation d’une structure de dictionnaire avec une liste chaînée . . . . . . . . . . . . . . 99
8.3.2 Structure d’arbre binaire de recherche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
8.3.3 Implémentation des ABR en Caml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
8.3.4 Implémentation de la structure de dictionnaire avec des ABR . . . . . . . . . . . . . . . . . . . 100
8.3.5 Arbres AVL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
8.3.6 Rotations et maintien de la structure d’arbre AVL . . . . . . . . . . . . . . . . . . . . . . . . . 102
8.3.7 Opérations sur les arbres AVL en Caml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103

9 Preuves par induction 105


9.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
9.2 Définitions inductives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
9.2.1 Le théorème du point fixe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
9.2.2 Des exemples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
9.3 Preuves par induction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
9.4 Induction non ambiguë et définitions de fonctions sur un ensemble inductif . . . . . . . . . . . . . . . 107

Svartz Page 7/187


TABLE DES MATIÈRES Lycée Masséna

9.4.1 Définition non ambiguë d’un ensemble inductif . . . . . . . . . . . . . . . . . . . . . . . . . . . 107


9.4.2 Ordre sur un ensemble inductif . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
9.4.3 Fonctions sur un ensemble inductif . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
9.4.4 Analyse d’une fonction récursive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109

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

11 Graphes non pondérés 121


11.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
11.2 Vocabulaire des graphes et propriétés mathématiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
11.2.1 Graphes non orientés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
11.2.2 Arbres au sens des graphes non orientés. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
11.2.3 Graphes orientés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 124
11.3 Implémentation des graphes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127
11.3.1 Implémentation « générale » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127
11.3.2 Implémentation « creuse » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 128
11.3.3 Représentation « dense » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
11.3.4 D’une représentation à une autre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
11.4 Parcours de graphes donnés par liste d’adjacence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
11.4.1 Parcours générique de graphe depuis un sommet source . . . . . . . . . . . . . . . . . . . . . . 131
11.4.2 Parcours en largeur et plus courts chemins . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
11.4.3 Parcours en profondeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
11.5 Applications du parcours en profondeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
11.5.1 Composantes connexes d’un graphe non orienté . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
11.5.2 Tri topologique d’un graphe orienté sans circuit (HP) . . . . . . . . . . . . . . . . . . . . . . . 134
11.5.3 Calcul des composantes fortement connexes d’un graphe orienté (HP) . . . . . . . . . . . . . . 137
11.6 Résolution du problème 2-SAT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
11.6.1 Construction d’un graphe à partir d’une instance de 2-SAT . . . . . . . . . . . . . . . . . . . . 139
11.6.2 À la recherche d’une distribution de vérité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
11.6.3 Complexité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140

12 Graphes pondérés 141


12.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 141
12.2 Définition et représentations des graphes pondérés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 141
12.2.1 Pondération . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 141
12.2.2 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 141
12.3 Définitions et premières propriétés sur les plus courts chemins . . . . . . . . . . . . . . . . . . . . . . . 142
12.3.1 Définition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
12.3.2 Différents problèmes de plus courts chemins . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143
12.3.3 Optimalité des solutions aux sous-problèmes . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143

Svartz Page 8/187


TABLE DES MATIÈRES Lycée Masséna

12.4 Plus courts chemins à origine unique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143


12.4.1 Relâchement d’arcs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143
12.4.2 Algorithme de Bellman-Ford (HP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
12.4.3 Algorithme de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
12.5 Plus courts chemins pour tous couples de sommets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147
12.5.1 Multiplication de matrices (HP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
12.5.2 Algorithme de Floyd-Warshall . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
12.5.3 Résumé des algorithmes de plus courts chemins . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
12.6 Arbre couvrant de poids minimal (HP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152

13 Langages et expressions rationnelles 155


13.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 155
13.2 Mots sur un alphabet . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
13.2.1 Définition et structure mathématique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
13.2.2 Lemme de Lévi et application à la commutativité . . . . . . . . . . . . . . . . . . . . . . . . . . 156
13.2.3 Mots de Dyck . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
13.3 Langages . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
13.3.1 Définition et cardinalité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
13.3.2 Opérations sur les langages . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
13.3.3 Expressions rationnelles et langages rationnels . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
13.3.4 Quelques réductions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
13.4 Langages locaux et expressions rationnelles linéaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
13.4.1 Expressions rationnelles linéaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
13.4.2 Langages locaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
13.4.3 Propriétés de clôture des langages locaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 162
13.5 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
13.6 Équations aux langages (HP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164

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

15 Structures usuelles en Ocaml 183


15.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
15.2 Piles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
15.2.1 Fonctions usuelles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
15.2.2 Fonctions additionnelles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
15.3 Files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
15.3.1 Fonctions usuelles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
15.3.2 Fonctions additionnelles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
15.4 Dictionnaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184

Svartz Page 9/187


TABLE DES MATIÈRES Lycée Masséna

15.5 Files de priorité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184


15.6 Créer un module en Ocaml . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186

Svartz Page 10/187


Lycée Masséna

Première partie

Programme de première année

Svartz Page 11/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

Chapitre 0

Introduction au langage OCaml

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.

0.1 Caml : un langage fonctionnel (permettant la programmation impé-


rative)
On distingue deux principaux styles de langages de programmation : les langages impératifs, et les langages fonc-
tionnels.

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.

Programmation fonctionnelle. La programmation fonctionnelle est un paradigme de programmation qui consi-


dère le calcul comme l’évaluation de fonctions mathématiques. Ainsi, un programme est une fonction au sens mathé-
matique, l’exécution d’un programme étant l’évaluation d’une fonction. Le programmeur écrivant un programme dans
le paradigme fonctionnel va écrire des fonctions, simples à la base, puis de plus en plus complexes : une fonction déja
écrite sert de « boite noire » dans une autre. Dans un langage fonctionnel, il n’y a pas de différence de nature entre une
fonction et un objet simple (un entier ou un flottant, par exemple). Une fonction est une expression comme une autre et
à ce titre pourra elle-même être l’argument ou le résultat d’une autre fonction. En programmation fonctionnelle, on ne
dispose pas d’instructions permettant de modifier l’état du programme, il n’y a donc pas de boucles (qui changeraient
l’état) : celles-ci sont remplacées par l’usage de la récursivité (capacité d’une fonction à s’appeler elle même).
En pratique, les langages fonctionnels sont écrits dans un langage de plus bas niveau (plus proche du langage
machine), et masquent à l’utilisateur la nature impérative du processeur qui va exécuter le programme. OCaml est un
langage fonctionnel, écrit en C (comme Python d’ailleurs) qui est un langage impératif de bas niveau.

Svartz Page 13/187


0.2. UN LANGAGE FORTEMENT TYPÉ Lycée Masséna

Exemple : la factorielle. Les deux algorithmes suivants donnent des descriptions impérative et fonctionnelle de la
fonction factorielle.

Algorithme 0.1 : Factorielle impéra- Algorithme 0.2 : Factorielle fonctionnelle


tive (fact)
Entrée : Un entier n ≥ 0 Entrée : Un entier n ≥ 0
f ← 1; si n = 0 alors
pour i allant de 1 à n faire faire Renvoyer 1
f ←f ×i sinon
Renvoyer f Renvoyer n × fact(n − 1)

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.

0.2 Un langage fortement typé


Commençons par un calcul simple dans l’interpréteur :

# 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 .

0.3 Types simples


0.3.1 Type « rien »
Le type unit en Caml correspond au None en Python. Il n’y a qu’une constante de ce type-là, dont l’utilité
apparaîtra plus tard. On peut la construire à l’aide de deux parenthèses :

# () ;;
- : 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.

Svartz Page 14/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

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.

0.4 Déclaration, déclaration locale, déclaration simultanée, références


0.4.1 Déclaration
Pour déclarer une variable, on utilise let. La déclaration permet de déclarer une variable globale qui ne change
pas à moins qu’une autre déclaration globale ne l’écrase. Parler de variable ici est donc abusif : on déclare plutôt une
constante ! En effet, on effectue simplement une liaison entre un nom (le nom de la variable) et une valeur.
# let x = 44 ;;
val x : int = 44

3. Non, il n’y a pas d’opérateur d’exponentiation sur les entiers.


4. Variantes : & pour le et logique et or pour le ou. Comme and est utilisé pour la déclaration simultanée de variables (voir la suite), je
vous conseille d’utiliser && et || pour éviter toute confusion.

Svartz Page 15/187


0.4. DÉCLARATION, DÉCLARATION LOCALE, DÉCLARATION SIMULTANÉE, RÉFÉRENCESLycée Masséna

0.4.2 Déclaration locale


Le même mot clé let, combiné avec in, sert à effectuer une déclaration locale. Si la variable utilisée était déja
associée √
à une √
valeur,√celle-ci est temporairement oubliée, mais retrouvée à la fin de l’exécution. Voici un moyen de
4 4
calculer 4 5 + 52 + 53 à l’aide d’une déclaration locale :
# let x= 5.0 ** 0.25 in x +. x *. x +. x ** 3.0 ;;
- : float = 7.07511828360311945
# x ;;
- : int = 44

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

0.4.3 Déclaration simultanée


On peut déclarer simultanément deux variables avec le mot clé and :
# let x = 0 and y = 1 ;;
val x : int = 0
val y : int = 1

Bien sûr, cette déclaration peut également être locale.


# let x=0 and y=1 in x+y ;;
- : int = 1

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.

Svartz Page 16/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

# 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

0.5 Quelques types plus complexes


0.5.1 Tuples
Les tuples (n-uplets en français) sont similaires aux tuples de Python : ils permettent de construire des séquences
de valeurs de type possiblement différents.

# (true, 0, atan 1.0) ;;


- : bool * int * float = (true, 0, 0.785398163397448279)

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 :

# let a, b = (0, 1.0) ;;


val a : int = 0
val b : float = 1.

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 :

# fst (1, 5.0) ;;


- : int = 1
# snd (0, true) ;;
- : bool = true

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|]

5. Les listes Python sont en fait des tableaux redimensionnables inhomogènes.

Svartz Page 17/187


0.5. QUELQUES TYPES PLUS COMPLEXES Lycée Masséna

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.

# let t = [|0; 5; 7|] ;;


val t : int array = [|0; 5; 7|]
# t.(0) <- 2 ;;
- : unit = ()
# t ;;
- : int array = [|2; 5; 7|]

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.

0.5.3 Chaînes de caractères


Contrairement à Python, Caml fait la distinction entre un caractère (type char) et une chaîne de caractères (type
string). D’un point de vue syntaxique, les caractères sont encadrés par des apostrophes (touche 4 du clavier), et les
chaînes par des guillemets (touche 3). D’un point de vue sémantique, les chaînes sont très semblables à des char array,
à ceci près qu’elles sont immuables 7 , comme en Python.

# 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]

Svartz Page 18/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

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.

0.6.1 Quelques exemples


# int_of_float ;;
- : float -> int = <fun>
# int_of_float (3.5) ;;
- : int = 3
# fst ;;
- : 'a * 'b -> 'a = <fun>
# fst (true, 5.4) ;;
- : bool = true

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.

0.6.2 Fonctions à un seul argument. Appel


Pour créer une fonction à un seul argument, on utilise function. Par exemple :
# function x -> x+1 ;;
- : int -> int = <fun>

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.

Svartz Page 19/187


0.6. FONCTIONS Lycée Masséna

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 !

On déclarera une fonction à un seul argument avec la syntaxe let f x = expression

0.6.3 Curryfication des fonctions


En mathématiques, lorsqu’on considère une fonction ayant deux arguments, c’est qu’on considère une application
de la forme :
f : E×F → G
(x, y) 7−→ f (x, y)
On pourrait considérer de telles fonctions systématiquement, lorsqu’on veut écrire des fonctions à plusieurs arguments.
C’est le cas de fst vue plus haut, par exemple. Mais en informatique, il est pratique de procéder autrement.
Dans la suite, on note F(E, F ) l’ensemble des applications de E dans F . Notons qu’il y a une bijection naturelle
entre F(E ×F, G) (applications de E ×F dans G) et F(E, F(F, G)) (applications de E vers l’ensemble des applications
de F dans G). Cette bijection est la suivante :

ϕ : F(E × F, G) → F(E, F(F, G))


f 7−→ x 7→ (y 7→ f (x, y))
L’intérêt est notamment qu’avec la seconde formulation, on peut considérer des applications partielles. Par exemple,
avec la fonction max (à deux arguments), la deuxième formulation permet de construire facilement la fonction y 7→
max(20, y) qui donne le maximum entre y et 20. La plupart des fonctions Caml sont données sous la seconde forme,
appelée forme curryfiée 10 . Regardons quelques fonctions :

# 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.

0.6.4 Création de fonctions curryfiées


Recréons pour l’exemple la fonction somme sur les entiers. L’opérateur + de Caml est un opérateur infixe (c’est-
à-dire qu’il s’utilise sous la forme a op b, mais on peut le transformer en opérateur préfixe (c’est-à-dire qu’il s’utilise
sous la forme op a b, donc comme une fonction) en l’encadrant entre parenthèses :

# (+) ;;
- : 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.

Svartz Page 20/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

# function x -> (function y -> x+y) ;;


- : int -> int -> int = <fun>

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

0.6.5 Création de fonctions non curryfiées


Il sera toutefois utile de temps en temps de créer des fonctions non curryfiées. Par exemple, si on veut travailler
avec les points du plan, on travaillera explicitement avec des couples de type int * int ou float * float. On peut
déclarer une fonction non curryfiée avec let également :
# let f (x,y) = x**2. +. y**2. <= 1. ;;
val f : float * float -> bool = <fun>

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>

L’usage de fst et snd était également possible.

0.7 Conditions et boucles en Caml


0.7.1 Expressions conditionnelles
La syntaxe générale d’une expression conditionnelle en Caml est de la forme if cond then a else b où cond
est une expression booléenne, et a et b sont des expressions de même type. Le type de l’expression (détecté avant
exécution !) est ce type commun. Le résultat peut être utilisé dans d’autres expressions.

# if true then 1 else 0 ;;


- : int = 1
# 1+(if true then 1 else 0) ;;
- : int = 2

Si a et b sont deux expressions de types différentes, l’interpréteur avertit immédiatement d’une erreur.

# if true then 1.0 else 2 ;;


Characters 22-23:
if true then 1.0 else 2 ;;
^
Error: This expression has type int but an expression was expected of type float

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 :

Svartz Page 21/187


0.7. CONDITIONS ET BOUCLES EN CAML Lycée Masséna

# if true then print_string "c'est vrai !" ;;


c'est vrai !- : 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 = ()

0.7.2 Séquence d’instructions


En Caml, deux expressions successives sont séparées par un point virgule. Dans une suite d’expressions successives,
toutes doivent avoir le type unit, sauf peut-être la dernière. Le type de l’expression totale est celui de la dernière
expression.

# 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 :

# let x = ref 0 in if !x > 0 then x:= !x + 1 ; print_int !x ;;


0- : unit = ()
# let x = ref 0 in if !x > 0 then begin x:= !x + 1 ; print_int !x end ;;
- : unit = ()

(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 :

# let x = ref 0 in if !x > 0 then (x:= !x + 1 ; print_int !x) ;;


- : unit = ()

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).

Svartz Page 22/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

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.

peut être implémentée comme suit.


let sgn x=if x=0 then 0 else abs(x)/x ;;

Une autre manière est d’utiliser un filtrage, avec le mot-clé function.


let sgn = function
| 0 -> 0
| x -> abs(x) / x
;;

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.

0.8.2 Règles du filtrage sur motif


Lors d’un filtrage, les cas successifs sont examinés un à un, et le premier qui « colle » à l’expression examinée est
réalisée, pas les autres. Voici par exemple une réécriture du « ou exclusif » en Caml, sur les booléens :
let xor a b=match (a,b) with
| true, true -> false
| false, true -> true
| true, false -> true
| false, false -> false
;;

Svartz Page 23/187


0.8. FILTRAGES Lycée Masséna

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 :

# match true with | true -> 0 | false -> "erreur" ;;


Characters 39-47:
match true with | true -> 0 | false -> "erreur" ;;
^^^^^^^^
Error: This expression has type string but an expression was expected of type int

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) :

let action ()=match 1+[Link] 6 with


| 1 -> "un pas à droite"
| 2 -> "un pas à gauche"
| 3 -> "demi tour"
| 4 -> "grand écart"
| 5 -> "saut perilleux"
| 6 -> "vrille"
;;

En effet, la fonction est acceptée, mais le filtrage non exhaustif signalé :

Warning 8: this pattern-matching is not exhaustive.


Here is an example of a value that is not matched: 0
val action : unit -> string = <fun>

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 :

#match true with | _ -> 0 | false -> 1 ;;


Toplevel input:
>match true with | _ -> 0 | false -> 1 ;;
> ^^^^^
Warning: this matching case is unused.
- : int = 0

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.

Svartz Page 24/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

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)) :

let arg (x,y)= match (x,y) with


| (0., 0.) -> failwith "zero"
| (0., y) when y>0. -> 2. *. atan 1.
| (0., y) -> -. 2. *. atan 1.
| (x,_) when x>0. -> atan (y/. x)
| _ -> atan (y/. x) +. 4. *. atan 1.
;;

0.9 Types enregistrement et types somme


On va maintenant voir comment construire de nouveaux types à partir de types existants ou non.

0.9.1 Types enregistrement


Principe. Les types enregistrement (ou types produit) sont similaires à des n-uplets, donc à un produit cartésien
de types, à quelques exceptions près :
— les composantes ont des noms (étiquettes ou champs) ;
— on peut déclarer une ou plusieurs composantes comme modifiable (mutable) ;
— il n’y a pas d’ordre dans les champs d’un enregistrement.
L’utilisation d’enregistrements, en particulier modifiables, se fait beaucoup en programmation impérative. Voici un
exemple :

#type personne = {nom: string ; prenom: string ; age: int} ;;


type personne = { nom : string; prenom : string; age : int; }
#let a = {nom="Dupond" ; age=42 ; prenom="Jean"} ;;
val a : personne = {nom = "Dupond"; prenom = "Jean"; age = 42}
# [Link] ;;
- : string = "Dupond"

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 :

let est_majeur p=match p with


| {prenom = _ ; nom = _ ; age = x} when x>=18 -> true
| _ -> false
;;

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.

Svartz Page 25/187


0.9. TYPES ENREGISTREMENT ET TYPES SOMME Lycée Masséna

let est_majeur p=match p with


| {age = x} when x>=18 -> true
| _ -> false
;;

Bien sûr, [Link] >= 18 était suffisant ici.

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 :

#type personne = {nom: string ; prenom: string ; mutable age: int} ;;


type personne = {nom: string ; prenom: string ; mutable age: int;} ;;
#let a = {nom="Dupond" ; age=42 ; prenom="Jean"} ;;
val a : personne = {nom = "Dupond"; prenom = "Jean"; age = 42}
#[Link] <- 43 ;;
- : unit = ()
#a;;
- : personne = {nom = "Dupond"; prenom = "Jean"; age = 43}

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 couple = {f: 'a ; s: 'a} ;;


type 'a couple = { f : 'a; s : 'a; }
# {f=5; s=2} ;;
- : int couple = {f = 5; s = 2}
# {f=true; s=false} ;;
- : bool couple = {f = true; s = false}

Bien sûr, on peut utiliser autant de types polymorphes que nécessaire :

# 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é.

type 'a reference_perso = {mutable contenu: 'a} ;;


let creer_ref x = {contenu = x} ;;
let acceder_ref r = [Link] ;;
let modifier_ref r x = [Link] <- x ;;

À la compilation, voici les types obtenus :

type 'a reference_perso = { mutable contenu : 'a; }


val creer_ref : 'a -> 'a reference_perso = <fun>
val acceder_ref : 'a reference_perso -> 'a = <fun>
val modifier_ref : 'a reference_perso -> 'a -> unit = <fun>

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>

Svartz Page 26/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

0.9.2 Types somme


Principe. Les types produit correspondait à des produits cartésiens, les types sommes correspondent à des unions
disjointes. De même que l’on utilise des champs pour désigner les composantes d’un type produit, on utilise des
constructeurs pour indiquer dans quelle partie de l’union disjointe on se situe. Les constructeurs peuvent être constants,
ou d’un certain type. Voici d’abord un type constitué de constructeurs constants, qui redéfinissent les booléens :

# type booleen = Vrai | Faux ;;


type booleen = Vrai | Faux
# Vrai ;;
- : booleen = Vrai

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 nombre = Ent of int | Flo of float ;;


type nombre = Ent of int | Flo of float
# Flo 4.5 ;;
- : nombre = Flo 4.5

On peut bien sûr mélanger constructeurs constants ou non :

#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

(Oui, il manque la couleur. On pourrait facilement l’intégrer !)

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 :

let negation b=match b with


| Vrai -> Faux
| Faux -> Vrai
;;

Et une autre sur les nombres :


let valeur_absolue x=match x with
| Ent a -> Ent (abs a)
| Flo a -> Flo (abs_float a)
;;

Et enfin une qui donne la valeur d’une carte de tarot 14 :

let point_tarot c=match c with


| Roi | Excuse -> 4.5
| Dame -> 3.5
| Cavalier -> 2.5
| Valet -> 1.5
| Atout x when x=1 || x=21 -> 4.5
| _ -> 0.5
;;

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.

type ('a,'b) union = A of 'a | B of 'b ;;

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).

Svartz Page 27/187


0.10. EXCEPTIONS Lycée Masséna

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
;;

est l’implémentation Caml (complètement artificielle) de la fonction :

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 » :

15. qui n’a pas à être exhaustif.

Svartz Page 28/187


CHAPITRE 0. INTRODUCTION AU LANGAGE OCAML Lycée Masséna

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 :

exception Indice of int ;;

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
;;

Elle utilise une exception « de type entier ».

Svartz Page 29/187


0.10. EXCEPTIONS Lycée Masséna

Svartz Page 30/187


CHAPITRE 1. STRUCTURE DE DONNÉES USUELLES Lycée Masséna

Chapitre 1

Structure de données usuelles

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 ;

Svartz Page 31/187


1.3. FILES Lycée Masséna

— 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.
La pile est un outil de base en informatique, dont les utilisations sont multiples. Voici quelques exemples :
— la gestion des appels de fonctions dans l’exécution d’un programme se fait via une pile d’appels : lorsqu’une
fonction est appelée, les informations relatives à l’appel (adresse de retour, notamment) sont empilées sur la pile
d’appels, et seront dépilées lorsque la fonction terminera son exécution ;
— la gestion des boutons « page précédente » et « page suivante » d’un navigateur utilise deux piles ;
— le parcours en profondeur d’un graphe, déja évoqué, fait usage d’une pile.
Il existe une structure déja implémentée en Ocaml : le module Stack.

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.4 Files de priorité


Une file de priorité suit le même principe qu’une file, mais à chaque élément inséré dans la file est attaché une
priorité, en général représentée par un entier. La file de priorité stocke donc des couples (e, p), où e est un élément et
p un entier (la priorité). Le prochain élément à sortir de la file est celui qui a la plus grande priorité (on parle de file
de priorité max ou min suivant si on prend le plus grand ou le plus petit entier p).
Définition 1.4. Une file de priorité est une structure abstraite, supportant les opérations suivantes :
— création d’une file de priorité vide ;
— test d’égalité au vide ;
— retrait de l’élément de plus grande priorité d’une file non vide ;
— ajout d’un élément avec une priorité donnée ;
— modification de la priorité d’un élément (cette opération a un sens seulement si on impose l’unicité des éléments
e stockés dans la file de priorité, ce qui est le cas en général).
Quelques exemples d’utilisation :
— le système d’exploitation gère les processus à exécuter via des priorités. Une tâche de moindre importance sera
mise en attente pour être exécutée par le processeur après celles de plus grande priorité ;
— il en va de même pour les documents gérés par une imprimante « haut de gamme » : il est possible d’augmenter
ou de diminuer la priorité d’un document ;
— le parcours en largeur d’un graphe se généralise à des graphes pondérés (les arêtes sont munies d’un poids supposé
positif) via l’algorithme de Dijkstra : les sommets à explorer en priorité sont ceux qui ont la plus petite distance
à l’origine, l’ordre d’exploration est donc géré par une file de priorité min. Cet algorithme permet de calculer
toutes les distances entre l’origine du parcours et chacun des sommets.

Svartz Page 32/187


CHAPITRE 1. STRUCTURE DE DONNÉES USUELLES Lycée Masséna

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).

Svartz Page 33/187


1.5. DICTIONNAIRES Lycée Masséna

Svartz Page 34/187


CHAPITRE 2. PROGRAMMATION IMPÉRATIVE EN CAML Lycée Masséna

Chapitre 2

Programmation impérative en Caml

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 Analyse d’algorithmes : terminaison, correction, complexité


Pour décrire un algorithme, on lui donne en général un nom, on précise quels sont les paramètres qu’il peut prendre
en entrée et le résultat qu’il est sensé renvoyer. On précise aussi de quelle manière il agit sur son environnement :
modification de la mémoire, affichage éventuel à l’écran, etc... Tout ceci constitue la spécification de l’algorithme.
Dans nos algorithmes, outre les opérations d’affectations, et de manipulations des variables, on trouve un découpage
en blocs simples correspondant aux instructions conditionnelles et aux boucles. Ce découpage en blocs simples est
essentiel : pour montrer que notre algorithme se termine, qu’il calcule ce qu’il est sensé calculer et estimer son coût,
il suffit d’analyser chacun des blocs.

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 :

Svartz Page 35/187


2.1. ANALYSE D’ALGORITHMES : TERMINAISON, CORRECTION, COMPLEXITÉ Lycée Masséna

— la propriété est vérifiée avant la boucle ;


— si la propriété est vérifiée en haut du corps de boucle, alors elle est vérifiée en bas du corps de boucle.
On en conclut (notamment) que la propriété est vérifiée après la boucle. La démonstration qu’une propriété est
bien un invariant de boucle est très similaire à une démonstration par récurrence.

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 :

for i=id to if do let i = ref id ;


instructions while !i <= if do
done ; instructions ;
incr i
done ;

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.

Svartz Page 36/187


CHAPITRE 2. PROGRAMMATION IMPÉRATIVE EN CAML Lycée Masséna

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.

2.1.4 Un exemple important : l’exponentiation


Un exemple important est le calcul de xn pour un entier naturel n donné. Il n’existe pas de fonction permettant
ce calcul sur les entiers en Caml. On présente ici deux fonctions permettant ce calcul ; l’une est naïve, l’autre est plus
élaborée.

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

Svartz Page 37/187


2.2. LES TABLEAUX EN CAML Lycée Masséna

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.

2.2 Les tableaux en Caml


Les tableaux ont été décrits dans le chapitre 0. Voyons quelques fonctions sur ce type.

2.2.1 Quelques exemples de fonctions basiques


Voici quelques fonctions de base qu’il faut savoir recoder vite : tester l’appartenance d’un élément à un tableau,
calculer le minimum d’un tableau, ou le nombre d’occurences d’un élément dans un tableau (rappel : incr n est
équivalent à n:= !n + 1).

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

Svartz Page 38/187


CHAPITRE 2. PROGRAMMATION IMPÉRATIVE EN CAML Lycée Masséna

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
;;

On peut aussi utiliser une exception, comme vu dans l’introduction :


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
;;

2.2.2 Recherche dichotomique dans un tableau trié


Si le tableau est trié dans l’ordre croissant, on peut accélérer considérablement la recherche d’un élément : si l’on
sait que l’élément à chercher ne peut se trouver qu’entre
j k indices g inclus et d exclus, on peut discriminer la moitié
les
de la portion en considérant l’élément d’indice m = g+d 2 :
— si l’élément d’indice m est l’élément cherché, on a terminé ;
— si l’élément d’indice m est strictement inférieur à l’élément cherché, celui-ci ne peut se trouver que dans la portion
entre l’indice m + 1 inclus et d exclus ;
— si l’élément d’indice m est strictement supérieur à l’élément cherché, celui-ci ne peut se trouver que dans la
portion entre l’indice g inclus et m − 1 exclus ;
Initialement, on a g, d = 0, n avec n la taille du tableau. On peut s’arrêter lorsque g = d : l’élément ne se trouve pas
dans le tableau.
exception Trouve ;;
let recherche_dicho t x=
try
let g, d=ref 0, ref ([Link] t) in
while !g < !d do
let m=( !g + !d) / 2 in
if t.(m) = x then raise Trouve ;
if t.(m) > x then d:= m else g:= m+1
done ;
false
with Trouve -> true
;;

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.

Svartz Page 39/187


2.2. LES TABLEAUX EN CAML Lycée Masséna

2.2.3 Exemples : les algorithmes de tri


On se donne un tableau dont les éléments sont comparables : entiers, flottants, voire même chaînes de caractères
pour l’ordre lexicographique (celui du dictionnaire...), et on veut le trier dans l’ordre croissant. On détaille ici les
algorithmes de tris « naifs » les plus classiques. Ceux-ci sont quadratiques (complexité O(n2 ) avec n la taille du
tableau) et sont donc inefficaces pour de grands tableaux. On leur préférera l’un des tris récursifs (qu’on verra plus
tard) dès que le nombre d’éléments à trier dépasse environ 50.

Tri par sélection


Ce tri particulièrement simple est peut-être celui auquel on pense en premier lorsqu’on écrit un algorithme de tri.

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.

Svartz Page 40/187


CHAPITRE 2. PROGRAMMATION IMPÉRATIVE EN CAML Lycée Masséna

Tri par insertion

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 &&.

Preuve de l’algorithme. La boucle while admet pour invariant :

Inv : Pour tout k tel que j < k ≤ i, t.(k)>x.

En sortie de boucle while, la condition !j>0 && t.(!j-1)> !x est fausse, ainsi la boucle for possède l’invariant
suivant :

Invi : Les éléments t.(0),...,t.(i-1) sont triés dans l’ordre croissant.

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.

2.3 Implémentation d’une pile et d’une file dans un tableau


On a décrit quelques structures abstraites dans le chapitre précédent, on va voir que deux d’entre elles peuvent
s’implémenter facilement à l’aide d’un tableau.

Svartz Page 41/187


2.3. IMPLÉMENTATION D’UNE PILE ET D’UNE FILE DANS UN TABLEAU Lycée Masséna

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).

Représentation dans un tableau


La représentation choisie est celle d’un enregistrement dans lequel sont stockés :
— la capacité capacite : nombre maximal d’éléments que l’on peut stocker dans la pile ;
— le nombre d’éléments nb présents dans la pile ;
— un tableau contenu de taille capacite, dont les nb premiers éléments sont les éléments présents dans la pile, les
autres éléments du tableau ne sont pas des éléments de la pile. Le sommet est l’élément d’indice nb-1.

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} ;;

let creer_pile c x={capacite = c ; nb = 0 ; contenu = [Link] c x} ;;

let pile_vide p = [Link] = 0 ;;

let pile_pleine p = [Link] = [Link]

let empiler p x=match pile_pleine p with


| true -> failwith "pile pleine"

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 !

Svartz Page 42/187


CHAPITRE 2. PROGRAMMATION IMPÉRATIVE EN CAML Lycée Masséna

| false -> [Link].([Link]) <- x ; [Link] <- [Link] + 1


;;

let sommet p=match pile_vide p with


| true -> failwith "pile vide"
| false -> [Link].([Link] -1)
;;

let depiler p=match pile_vide p with


| true -> failwith "pile vide"
| false -> [Link] <- [Link] - 1 ; [Link].([Link])
;;

Voici un exemple de maniement d’une pile d’entiers :

# 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).

Représentation dans un tableau


On va donner une réalisation d’une file bornée à partir d’un tableau, semblable à celle d’une pile. C’est un peu plus
complexe pour une file, parce qu’on ne réalise pas l’ajout et la suppression d’éléments au même endroit. L’astuce est
ici de considérer le tableau contenu comme « circulaire ». On utilise donc deux indices supplémentaires (mutables),
qui indiquent les positions de la tête de file (le premier à avoir été inséré dans la file, donc le prochain à sortir), et de
la queue (position du prochain élément qui va être inséré). La représentation choisie est donc celle d’un enregistrement
dans lequel sont stockés :
— la capacité capacite ;
— le nombre d’éléments nb présents dans la file ;
— les indices tete et queue ;
— un tableau contenu dont les éléments entre les éléments présents dans la pile, les autres éléments du tableau ne
sont pas des éléments de la pile.

Svartz Page 43/187


2.3. IMPLÉMENTATION D’UNE PILE ET D’UNE FILE DANS UN TABLEAU Lycée Masséna

tete queue queue tete

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 :

queue − tete ≡ nb[capacite]

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 creer_file c x={capacite = c ; nb = 0 ; tete = 0 ; queue = 0 ; contenu=[Link] c x} ;;

let file_vide f=[Link] = 0 ;;

let file_pleine f=[Link] = [Link] ;;

let enfiler f x=match file_pleine f with


| true -> failwith "file pleine"
| false -> [Link].([Link]) <- x ; [Link] <- ([Link] + 1) mod [Link] ; [Link] <- [Link] + 1
;;

let defiler f = match file_vide f with


| true -> failwith "file vide"
| false -> let x=[Link].([Link]) in [Link] <- ([Link] + 1) mod [Link] ; [Link] <- [Link] - 1 ; x
;;

Voici un exemple avec une file d’entiers :

# 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.

Svartz Page 44/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

Chapitre 3

Récursivité et listes

3.1 Exemple introductif : la fonction factorielle


Une première définition basique d’une fonction récursive est plutôt simple : c’est une fonction qui s’appelle Qnelle-
même. Prenons un exemple classique : le calcul de la factorielle d’un entier positif. On définit, pour n ≥ 0, n! = i=1 i.
En Caml, on peut donc définir la factorielle comme :
let fact n=
let f = ref 1 in
for i=1 to n do
f:= !f * i
done ;
!f
;;

Une autre définition mathématique classique de la factorielle se fait par récurrence :



1 si n = 0
n! =
n × (n − 1)! sinon.
En reprenant quasiment mot pour mot cette dernière définition, on obtient la fonction Caml suivante :

let rec fact_rec n=match n with


| 0 -> 1
| _ -> n * fact_rec (n-1)
;;

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.

3.2 Pratique de la récursivité


3.2.1 Ce qui se passe « en interne » : la pile d’appels
En informatique, la pile d’exécution (ou pile d’appels, call stack en anglais) est une structure de données de type
pile, qui sert à enregistrer des informations au sujet des fonctions actives dans un programme. Une fonction active est
une fonction dont l’exécution n’est pas encore terminée.
L’utilisation principale de la pile d’appels est de garder la trace de l’endroit où chaque fonction active doit retourner
à la fin de son exécution. En pratique, lorsqu’une fonction est appelée par un programme, son adresse de retour (adresse
de l’instruction qui suit l’appel) est empilée sur la pile d’appels. En plus d’emmagasiner des adresses de retour, la pile
d’exécution stocke aussi d’autres valeurs, comme les variables locales de la fonction, les paramètres de la fonction,
etc 2 ...
1. Ce qui est nécessaire, car sinon la définition de fact_rec requiert celle d’une fonction fact_rec, non encore définie !
2. Ce mécanisme est totalement transparent pour l’utilisateur d’un langage de haut niveau comme Caml. On ne rentrera pas dans les
détails, le lecteur intéressé pourra consulter [Link]

Svartz Page 45/187


3.2. PRATIQUE DE LA RÉCURSIVITÉ Lycée Masséna

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.

3.2.2 Récursivité terminale


Un exemple : la fonction somme
Voici un autre exemple de fonction récursive : le calcul de la somme des entiers de 0 à n, pour n > 0. Elle est facile
à écrire :
let rec somme n=match n with
| 0 -> 0
| _ -> n + somme (n-1)
;;

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?).

Svartz Page 46/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

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.

Définition de la récursivité terminale


Définition 3.1. Une fonction f récursive est dite récursive terminale si tout appel récursif est le dernier calcul réalisé
par f .
La fonction somme_avec_acc respecte bien cette définition : lorsque l’on n’est pas dans le cas terminal n = 0, la
fonction calcule n − 1 et n + acc avant de réaliser l’appel récursif, qui est le dernier calcul effectué lors de cet appel de
fonction. L’intérêt d’avoir une fonction f récursive terminale est qu’un appel récursif à f ne nécessite pas d’empilement
sur la pile d’appels : l’appel récursif effectué peut prendre la place de l’appel en cours dans la pile d’appels, puisqu’il n’y
a plus d’instructions à effectuer une fois l’appel récursif terminé. Certains compilateurs, dont le compilateur Caml 4 ,
sont capables de détecter les fonctions récursives terminales, et d’en tirer profit pour diminuer les empilements dans
la pile d’appels.
En Caml, on fait donc souvent une transformation similaire à la précédente pour transformer une fonction récursive
en fonction récursive terminale. Il reste un petit détail : il est un peu pénible de devoir passer 0 en paramètre de la
fonction pour la valeur initiale de l’accumulateur. Une solution est de définir la fonction somme_avec_acc comme
interne à une autre fonction, qui se contentera de l’appeler avec acc=0. La fonction somme_avec_acc n’existant plus
dans le programme principal, on peut lui donner un nom plus court (souvent aux, pour auxiliaire) :
let somme2 n=
let rec aux n acc = match n with
| 0 -> acc
| _ -> aux (n-1) (acc + n)
in aux n 0
;;

3.2.3 Deux exemples de fonctions récursives


Algorithme d’Euclide. Une méthode récursive de calcul du PGCD de deux entiers positifs non tous deux nuls est
la suivante : 
a si b = 0 ;
PGCD(a, b) =
PGCD(b, a mod b) sinon.
Cette méthode mène à la fonction récursive terminale suivante :
let rec pgcd a b=match b with
| 0 -> a
| _ -> pgcd b (a mod b)
;;

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.

Svartz Page 47/187


3.2. PRATIQUE DE LA RÉCURSIVITÉ Lycée Masséna

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.

3.2.4 Récursivité croisée


On rappelle que and permet de définir simultanément plusieurs objets. Il est possible de définir simultanément
plusieurs fonctions, qui s’appellent mutuellement (on parle de récursivité mutuelle ou croisée). Par exemple :
let rec pair n=match n with
| 0 -> true
| _ -> impair (n-1)
and impair n=match n with
| 0 -> false
| _ -> pair (n-1)
;;

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...

Svartz Page 48/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

3.2.5 Attention aux appels qui se chevauchent !


Considérons l’exemple suivant : soit (Fn )n∈N la suite définie par

1 si n = 0 ou 1.
Fn =
Fn−1 + Fn−2 sinon
Vous aurez probablement reconnu la fameuse suite de Fibonacci. Une transcription récursive en Caml s’obtient
aisément :
let rec fibo n=match n with
| 0 | 1 -> 1
| _ -> fibo (n-1) + fibo (n-2)
;;

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

n−2 n−3 n−3 n−4

.. .. .. .. .. .. .. ..
. . . . . . . .

1 0 1 0

Figure 3.2 – L’arbre d’appels récursifs pour le calcul de (Fn )n∈N .

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 !

3.3 Un exemple plus complet : les tours de Hanoï


On a déja vu un avantage des fonctions récursives : leur formulation est plus simple que leur équivalent itératif.
Montrons un exemple de problème pour lequel une solution récursive est très adaptée, mais pour lequel une solution
itérative n’est pas facile à trouver : le problème des tours de Hanoï.
On dispose de n disques troués en leur centre, numérotés de 1 à n, de diamètres croissants. On se donne également
3 piquets (numérotés de A à C). Initialement, tous les disques sont enfilés sur le premier piquet, le plus grand étant à
la base, le plus petit au sommet, comme sur la figure 3.3.
Le but du jeu est d’amener les disques sur le troisième piquet, en suivant les règles suivantes :

Svartz Page 49/187


3.3. UN EXEMPLE PLUS COMPLET : LES TOURS DE HANOÏ Lycée Masséna

1
2
3
4
5
6
7

piquet A piquet B piquet C

Figure 3.3 – Le jeu de Hanoï : comment déplacer les 7 disques du piquet A au piquet C, en suivant les règles ?

— déplacer les disques un à un d’un piquet à un autre ;


— un disque ne doit jamais être posé sur un disque de diamètre inférieur.
La figure 3.4 montre les 7 mouvements à effectuer pour résoudre le jeu avec seulement 3 disques. On peut vérifier
qu’il faut par exemple 127 mouvements pour le jeu à 7 disques (voir la suite).

1
2 2
3 3 1 3 2 1

1 1
3 2 2 3 1 2 3

1
2 2
1 3 3

Figure 3.4 – Résolution du jeu de Hanoi pour n = 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

Svartz Page 50/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

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 Structure de liste chaînée


La liste chaînée est l’exemple le plus simple de type récursif, ou type défini par induction : le type se retrouve dans
la définition. Caml est extrêmement pratique pour définir et manipuler ces types, comme on va le voir par la suite.
Naturellement, les fonctions qui manipulent des types récursifs sont le plus souvent 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.

En figure 3.5 est représentée une liste chaînée d’entiers, à savoir :

Cons(12, Cons(99, Cons(37, Cons(8, [ ]))))

12 99 37 8

Figure 3.5 – La représentation interne d’une liste chaînée

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.

Svartz Page 51/187


3.4. STRUCTURE DE LISTE CHAÎNÉE Lycée Masséna

3.4.2 Le type list en Caml


En Caml, le type list est le type liste chaînée. On peut définir une liste en donnant la séquence des éléments de
la liste, entre crochets. Voici la liste précédente, en Caml.

#[12; 99; 37; 8] ;;


- : int list = [12; 99; 37; 8]

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 :

# let pref_cons (x,q) = x::q ;;


val pref_cons : 'a * 'a list -> 'a list = <fun>

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 :

let tete q=match q with


| [] -> failwith "liste vide"
| x::_ -> x
;;

let queue q=match q with


| [] -> failwith "liste vide"
| _::p -> p
;;

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

7. Remarque : la fonction [Link] existe depuis la version 4.03 de Ocaml.

Svartz Page 52/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

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.

3.4.3 Exemples de fonctions sur les listes


Parcours d’une liste. Le type liste étant récursif, les fonctions sur les listes sont en général récursives. La fonction
suivante est l’archétype d’une fonction sur une liste :
let rec parcours q=match q with
| [] -> ()
| x::p -> parcours p
;;

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
;;

8. « mem » pour member en anglais.

Svartz Page 53/187


3.4. STRUCTURE DE LISTE CHAÎNÉE Lycée Masséna

3.4.4 Construction de listes


Il existe plusieurs manières de construire des listes : une possibilité est de donner la liste des éléments « à la main ».
Sinon, il faut la construire via une structure itérative ou en faisant usage de récursivité. Donnons deux méthodes pour
construire la liste des entiers de 0 à n − 1.

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
;;

3.4.5 Une implémentation personnalisée des listes


Il est très facile de réécrire une implémentation personnalisée des listes Caml, à l’aide d’un type somme :
type 'a liste = Vide | Cons of 'a * 'a liste ;;

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
;;

let queue q=match q with


| Vide -> failwith "Vide"
| Cons (_, p) -> p
;;

let cons (x,q)=Cons (x, q) ;;

Voici les types des fonctions écrites :


tete : 'a liste -> 'a
queue : 'a liste -> 'a liste
cons : 'a * 'a liste -> 'a liste

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.

3.4.6 Implémentation de structures de pile et de file à l’aide de listes chaînées


On va implémenter une structure de pile et une structure de file à l’aide de listes chaînées. La différence avec
l’implémentation sur les tableaux est que la taille peut varier : il est peu coûteux (complexité O(1)) d’ajouter un
élément en tête d’une liste pour obtenir une nouvelle liste. On peut donc réaliser des piles et files non bornées : leur
capacité est virtuellement 9 infinie. On n’aura pas non plus à préciser un élément à la création pour spécifier le type
(comme on l’avait fait pour les tableaux), la liste vide étant polymorphe :
9. En pratique, la capacité reste naturellement bornée par la mémoire.

Svartz Page 54/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

#[] ;;
- : '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] ;;

let sommet p = match [Link] with


| [] -> failwith "pile vide"
| x::_ -> x
;;

let depiler p = match [Link] with


| [] -> failwith "pile vide"
| x::q -> [Link] <- q ; x
;;

Complexité des opérations. Elles se font toutes en temps constant O(1).

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 :

Entrée Première liste


Sens de la file
Sortie Seconde liste

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 :

let creer_file () = {entree = []; sortie = []} ;;


let file_vide f=[Link] = [] && [Link] = [] ;;
let enfiler f x=[Link] <- x::[Link] ;;

let rec defiler f=match [Link] with


| [] when [Link] = [] -> failwith "file vide"
| [] -> [Link] <- [Link] [Link] ; [Link] <- [] ; defiler f
| x::q -> [Link] <- q ; x
;;

Remarquez l’utilisation de la récursivité pour simplifier l’écriture de defiler.

Svartz Page 55/187


3.5. UN EXEMPLE FONDAMENTAL : LE TRI FUSION Lycée Masséna

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.

3.5 Un exemple fondamental : le tri fusion


Principe du tri. On termine ce chapitre par la présentation d’un algorithme fondamental : le tri fusion. C’est un
algorithme qui suit le principe « diviser pour régner » dont on reparlera plus tard. Le principe du tri fusion pour le
tri d’une liste ` est le suivant :
— si la liste ` possède zéro ou un élément, elle est triée ;
— sinon, il suffit de couper la liste en deux parties ayant le même nombre d’éléments (à un près), de les trier
récursivement, et de fusionner les deux listes triées obtenues.

É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 :

let rec fission q=match q with


| [] | [_] -> q, []
| x::y::p -> let a,b=fission p in x::a, y::b
;;

let rec fusion p1 p2=match p1, p2 with


| [],_ -> p2
| _, [] -> p1
| x::q1, y::_ when x<=y -> x::(fusion q1 p2)
| _,x::q2 -> x::(fusion p1 q2)
;;

Une fois ces deux fonctions écrites, l’implémentation du tri est un jeu d’enfant (attention à ne pas oublier le cas
terminal) :

let rec tri_fusion p=match p with


| [] | [_] -> p
| _ -> let a,b=fission p in fusion (tri_fusion a) (tri_fusion b)
;;

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 )).

Svartz Page 56/187


CHAPITRE 3. RÉCURSIVITÉ ET LISTES Lycée Masséna

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 :

# let f = fun x y -> if x = y then 0 else if x<y then -1 else 1 ;;


val f : 'a -> 'a -> int = <fun>
# [Link] f [5; 2; 3; 7; 59; 2; 8 ; 489; 44; 498; 11566; 16] ;;
- : int list = [2; 2; 3; 5; 7; 8; 16; 44; 59; 489; 498; 11566]

On obtient le même résultat avec la fonction (x, y) 7→ y − x.

Svartz Page 57/187


3.5. UN EXEMPLE FONDAMENTAL : LE TRI FUSION Lycée Masséna

Svartz Page 58/187


CHAPITRE 4. ANALYSE DES FONCTIONS RÉCURSIVES Lycée Masséna

Chapitre 4

Analyse des fonctions récursives

4.1 Introduction
On rappelle le code Caml de la fonction donnant la factorielle :

let rec fact n=match n with


| 0 -> 1
| _ -> n* fact (n-1)
;;

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.

Svartz Page 59/187


4.2. TERMINAISON Lycée Masséna

— L’ordre produit sur N2 , défini par

(a, b)  (c, d) ⇐⇒ a≤c et b≤d

est bien fondé. Attention : ce n’est pas un ordre total.


— L’ordre lexicographique sur N2 , défini par

(a, b)  (c, d) ⇐⇒ a<c ou a=c et b≤d

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 :

(a, b)  (c, d) ⇐⇒ a+c<b+d ou a+b=c+d et a≤c

Il est également bien fondé.

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.

Voyons maintenant comment montrer la terminaison d’une fonction récursive f .

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).

Svartz Page 60/187


CHAPITRE 4. ANALYSE DES FONCTIONS RÉCURSIVES Lycée Masséna

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)

Exemple 4.15. La fonction d’Ackermann suivante termine :


let rec ack n p=match (n,p) with
| 0,_ -> p+1
| _,0 -> ack (n-1) 1
| _ -> ack (n-1) (ack n (p-1))
;;

En effet, on prend ici N2 muni de l’ordre lexicographique.


— Les cas de bases sont les couples dont la première coordonnée est nulle.
— Sur (n, 0), la fonction se rappelle une seule fois avec un couple strictement plus petit.
— Sur (n, p) avec n et p tous deux non nuls, les appels récursifs sont sur les couples suivants :
(n, p − 1) et (n − 1, ack(n, p − 1))
qui sont bien plus petits.
Remarquez que la fonction d’Ackermann croît très très vite : on pourra à titre d’exercice calculer les ack i j pour
i ≤ 3, et vérifier que ack 4 0 vaut 13, ack 4 1 vaut 65533 et ack 4 2 vaut 265536 − 3... La terminaison n’implique
pas que le calcul effectif soit possible !
Exemple 4.16. La fonction de Morris suivante ne termine pas.
let rec morris n p=match (n,p) with
| 0,_ -> 1
| _ -> morris n (p-1) + morris (n-1) (morris n p) ;;

En effet, morris n p fait appel à morris n p.


Pour conclure, précisons qu’il n’est pas toujours facile de trouver un « bon ensemble » bien fondé et une application
ϕ associée. La terminaison de la fonction suivante est un problème ouvert.
let rec syracuse n=match n with
| 0 | 1 -> 1
| n when n mod 2 =0 -> syracuse (n/2)
| _ -> syracuse (3*n+1)
;;

Svartz Page 61/187


4.3. CORRECTION Lycée Masséna

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 :

let tri_selection q=match q with


| [] -> []
| _ -> let m,p=mini q in m::(tri_selection p)
;;

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.

4.4 Complexité des fonctions récursives


4.4.1 Introduction
P
La complexité d’une fonction récursive vérifie une relation de récurrence de la forme C(n) = C(ni ) + f (n),
qu’il va falloir résoudre. Les C(ni ) résulte des appels récursifs, le terme f (n) étant le coût de la fonction hors appels
récursifs. Par exemple sur les fonctions déja vues :

Exemple 4.18. — factorielle : C(n) = C(n − 1) + O(1) ;


— tri par sélection récursif : C(n) = C(n − 1) + O(n) ;
— tri fusion : C(n) = C(bn/2c) + C(dn/2e) + O(n) ;
— Hanoi : C(n) = 2C(n − 1) + O(1).

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.

Svartz Page 62/187


CHAPITRE 4. ANALYSE DES FONCTIONS RÉCURSIVES Lycée Masséna

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.

4.4.2 Premiers résultats


Les résultats de cette sous-section permettent de résoudre des « récurrences simples » comme celles satisfaites par
le tri par sélection ou la fonction résolvant les tours de Hanoï. On verra comment traiter des récurrences comme celle
issue du tri fusion dans la sous-section suivante. Le premier résultat qui suit permet de remplacer un terme f (n) par
un terme plus simple.
Pn Pn
Proposition 4.19. Soit (bn ), (b0n ) deux suites réelles positives. Si bn = O(b0n ) alors k=0 bk = O ( k=0 b0k ).
Pn Pn
Démonstration. — Si ( k=0 b0k )n est bornée, P alors ( k=0 b0k )n = Θ(1) (car la suite est non nulle à partir d’un
n
certain rang), et la majoration assure que ( k=0 bk )n est bornée également, d’où le résultat.
Pn
— Sinon, ( k=0 b0k )n tend Pnvers +∞. Soit
0
PnC > 00 et n0 tels que bn ≤ Cbn à partir du rang n0 . Alors c’est aussi le
cas pour la somme : k=n0 bk ≤ C k=n0 bk . Puisque la somme de droite est divergente, elle est non nulle à
partir d’un certain rang n1 , qu’on peut supposer être supérieur à n0 . Alors pour n ≥ n1 :
Pn Pn1 −1 Pn Pn1 −1
b b b
1 k k=0 bk
Pnk=0 0k ≤ Pnk=0 k0 + Pk=n n 0 ≤ C + P n 0
b
k=0 k b
k=n1 k k=n1 kb k=n1 bk
Pn Pn
et le quotient restant tend vers 0 en +∞. On a bien k=0 bk = O ( k=0 b0k ).

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

Et le résultat s’ensuit, d’après la proposition 4.19.


Remarque 4.23. Si bk = O(k α ) avec α quelconque et a > 1, la première conclusion est valable, la démonstration
étant la même.
Exemple 4.24. La complexité dans l’algorithme résolvant le problème de Hanoï vérifie C(n) = 2C(n − 1) + Θ(1),
donc C(n) = Θ(2n ), ce qu’on avait déja vu directement.

Svartz Page 63/187


4.4. COMPLEXITÉ DES FONCTIONS RÉCURSIVES Lycée Masséna

4.4.3 Récurrences « Diviser pour régner »


On s’intéresse aux récurrences de la forme

un = aub n c + bud n e + bn
2 2

La complexité dans le tri fusion est un cas particulier : a = b = 1 et bn = Θ(n).


Proposition 4.25. Soit (un )n∈N∗ une suite vérifiant la relation de récurrence pour n > 1 :

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

donc un ≥ un−1 et (un ) est croissante.

Théorème 4.28. Considérons une récurrence de la forme :

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).

Svartz Page 64/187


CHAPITRE 4. ANALYSE DES FONCTIONS RÉCURSIVES Lycée Masséna

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 .

Svartz Page 65/187


4.4. COMPLEXITÉ DES FONCTIONS RÉCURSIVES Lycée Masséna

Svartz Page 66/187


CHAPITRE 5. ALGORITHMES « DIVISER POUR RÉGNER » Lycée Masséna

Chapitre 5

Algorithmes « Diviser pour régner »

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.

5.2 Tri fusion : analyse


Pour le problème du tri d’une liste de taille n > 1, on avait suivi le principe précédent :
— découper la liste à trier en deux de tailles égales à un près via la fonction fission ;
— la fusion de deux listes triées via la fonction fusion ;
— appeler récursivement le tri sur les deux portions (étape « division ») avant de les fusionner (« règne »).

5.2.1 Rappel : le code du tri


let rec fission q=match q with
| [] | [_] -> q, []
| x::y::p -> let a,b=fission p in x::a, y::b
;;

let rec fusion p1 p2=match p1, p2 with


| [],_ -> p2
| _, [] -> p1
| x::q1, y::_ when x<=y -> x::(fusion q1 p2)
| _,x::q2 -> x::(fusion p1 q2)
;;

let rec tri_fusion p=match p with


| [] | [_] -> p
| _ -> let a,b=fission p in fusion (tri_fusion a) (tri_fusion b)
;;

5.2.2 Analyse des fonctions auxiliaires


Terminaison. La fonction fission termine sur des listes de tailles 0 ou 1, et sur une liste de taille n > 1, se rappelle
sur une liste de taille n − 2 : elle termine. De même, en notant n1 et n2 les tailles des listes en paramètre de la fonction
fusion, celle-ci termine si n1 = 0 ou n2 = 0, et sinon se rappelle récursivement sur deux listes dont la somme des
tailles est n1 + n2 − 1 : la quantité « somme des tailles des deux listes » décroît lors de l’appel récursif et la fonction
termine.

Svartz Page 67/187


5.3. ALGORITHMES DE MULTIPLICATION RAPIDE : POLYNÔMES ET MATRICES Lycée Masséna

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.

5.2.3 Analyse de la fonction de tri


Terminaison et correction. La fonction tri_fusion est correcte sur des listtes de tailles 0 ou 1, et pour n > 1
elle se rappelle récursivement sur des listes de tailles bn/2c < n et dn/2e, donc la fonction termine. Elle est correcte
par récurrence sur n et correction des fonctions fusion et fission.

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 .

5.3 Algorithmes de multiplication rapide : polynômes et matrices


Cette section aborde deux autres exemples, historiquement très importants, d’algorithmes « diviser pour régner ».
Le but est de multiplier rapidement deux polynômes ou deux matrices, de même taille.
Pour le premier problème (multiplication de deux polynômes de même taille n), la méthode naïve a une complexité
O(n2 ), et il a longtemps été conjecturé qu’il n’existait pas de meilleur algorithme. Karatsuba 2 découvrît la méthode
présentée ci-dessous en 1960 : celle-ci a une complexité en O(n1.59 ). Historiquement, la question que s’était posée
Karatsuba portait sur la multiplication
P d’entiers réprésentés en binaire. Mais un entier représenté dans une base b
ak bk et le polynôme ak X k sont deux objets très proches. Néanmoins la multiplication de polynômes
P
comme N =
est légèrement plus facile car il n’y a pas à gérer les retenues.
De la même manière, on a longtemps cru que la multiplication de deux matrices de taille n × n ne pouvait se faire
avec une complexité meilleure que O(n3 ). Strassen 3 découvrît les formules menant à son algorithme de multiplication
de complexité O(n2.81 ) en 1969.
Ces deux algorithmes ont eu une importance considérable, et les problèmes asssociés ont fait l’objet d’intenses
recherches depuis. La meilleure borne connue pour la multiplication de deux polynômes de taille n est quasi-optimale :
O(n log n log log n). Par contre, le problème de la multiplication matricielle résiste toujours, on sait 4 aujourd’hui
multiplier deux matrices de taille n × n avec une complexité O(n2.3728639 ). Il est conjecturé qu’il existe un algorithme
de complexité O(n2+ε ) pour tout ε > 0, mais la recherche actuelle en est encore très loin !
Enfin, mentionnons pour terminer un résultat établi en mars 2019, par David Harvey et Joris Van Der Hoeven :
il est possible de multiplier deux entiers de n bits en temps 5 O(n log n). L’optimalité de cette borne est encore une
conjecture.
1. On peut supposer C croissante, il suffit alors d’encadrer n entre deux puissances de 2 successives.
2. Anatoli Alekseïevitch Karatsouba, mathématicien soviétique puis russe (1937-2008).
3. Volker Strassen, mathématicien allemand né en 1936.
4. La constante cachée dans le O est tellement énorme que l’algorithme associé n’est pas utilisable en pratique. Celui-ci a été publié par
François le Gall en 2014.
5. Ce résultat a « résisté » longtemps ! Les dernières publications donnaient des bornes extrêmement proches, de la forme O(n log nf (n)),
où f est une fonction de croissance extrêmement lente (bien plus que le log), que les auteurs ont réussi à éliminer.

Svartz Page 68/187


CHAPITRE 5. ALGORITHMES « DIVISER POUR RÉGNER » Lycée Masséna

5.3.1 Multiplication rapide de polynômes : algorithme de Karatsuba


Le problème
Pn−1 Pn−1
Soit n un entier strictement positif. Considérons P = k=0 pk X k et Q = k=0 qk X k deux polynômes de même
taille n (la taille est égale au degré, plus 1 : c’est le nombre de coefficients). Les coefficients sont dans un anneau
commutatif A quelconque, qu’on pourra considérer comme étant l’ensemble des entiers Z : une opération dans A
(multiplication, addition, soustraction) est considérée comme élémentaire. On représente les polynômes P et Q par les
tableaux [|p0 ; p1 ; . . . ; pn−1 |] et [|q0 ; q1 ; . . . ; qn−1 |]. Le produit P × Q est un polynôme de degré 2n − 2. Comment
obtenir efficacement le tableau de ses 2n − 1 coefficients ?

Méthode naïve de résolution


Pn−1 Pn−1
Pour calculer naïvement le produit, on utilise simplement la relation P Q = k=0 j=0 pk qj X k+j . On en déduit
un algorithme 6 de complexité O(n2 ) répondant au problème :

Algorithme 5.1 : Multiplication naïve


Entrée : Deux polynômes P et Q de même taille n, représentés par leurs tableaux de coefficients.
Sortie : Le tableau de taille 2n − 1 associé au produit P × Q.
T ← un tableau de 2n − 1 zéros;
pour k entre 0 et n − 1 faire
pour j entre 0 et n − 1 faire
T [j + k] ← T [j + k] + pk × qj
retourner T

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 :

Algorithme 5.2 : L’algorithme de Karatsuba


Entrée : Deux polynômes P et Q de même taille n, représentés par leurs tableaux de coefficients.
Sortie : Le tableau de taille 2n − 1 associé au produit P × Q.
si n = 1 alors
retourner [|p0 × q0 |]
sinon
m = bn/2c;
Décomposer P = P0 + X m P1 , Q = Q0 + X m Q1 ;
T0 =Karatsuba(P0 , Q0 );
T1 =Karatsuba(P1 , Q1 );
T2 =Karatsuba(P0 + P1 , Q0 + Q1 );
retourner T0 + X m (T2 − T1 − T0 ) + X 2m T1

Terminaison et correction de l’algorithme de Karatsuba


La terminaison et la correction repose sur les mêmes principes que pour le tri fusion : pour n = 1, l’algorithme
termine (et est correct !), et pour deux polynômes de taille n > 1, algorithme fait 3 appels récursifs :
— P0 et Q0 sont de tailles 1 ≤ bn/2c < n ;
— P1 et Q1 , et donc P0 + P1 et Q0 + Q1 sont de tailles 1 ≤ dn/2e < n ;
Donc l’algorithme termine, et est correct car le produit P Q est recomposé correctement à l’aide de T0 , T1 et T2 .
6. On laisse au lecteur le soin de l’implémenter en Caml.

Svartz Page 69/187


5.3. ALGORITHMES DE MULTIPLICATION RAPIDE : POLYNÔMES ET MATRICES Lycée Masséna

Étude de la complexité de l’algorithme de Karatsuba

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.

let rec karatsuba p q=


let n=[Link] p in
if n=1 then [|p.(0)*q.(0)|] else begin
let k=n/2 in
let p0, p1=[Link] p 0 k, [Link] p k (n-k) and q0, q1=[Link] q 0 k, [Link] q k (n-k) in
let t0, t1=karatsuba p0 q0, karatsuba p1 q1 in
for i=0 to k-1 do
p1.(i) <- p0.(i) + p1.(i) ;
q1.(i) <- q0.(i) + q1.(i)
done ;
let t2=karatsuba p1 q1 in
let res=[Link] (2*n-1) 0 in
for i=0 to [Link] t0 - 1 do
res.(i) <- res.(i)+t0.(i) ;
res.(i+k) <- res.(i+k)-t0.(i)
done ;
for i=0 to [Link] t1 - 1 do
res.(i+k) <- res.(i+k)-t1.(i)+t2.(i) ;
res.(i+2*k) <- res.(i+2*k)+t1.(i)
done ;
res
end
;;

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.

deg(P ) = deg(Q) = n 100 500 1000 5000 10000 50000


multiplication naïve 0.006 0.16 0.63 15.96 63.3 1692
Karatsuba 0.07 0.15 0.48 7.75 23.2 288

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.

Svartz Page 70/187


CHAPITRE 5. ALGORITHMES « DIVISER POUR RÉGNER » Lycée Masséna

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

5.3.2 Algorithme de Strassen


Le problème
Soit n un entier strictement positif. On se donne A = (ai,j ) et B deux matrices carrées de même taille n × n, à
coefficients dans un anneau commutatif A. Le produit C = A × B est une matrice de taille n × n, le but est de calculer
ses n2 coefficients efficacement.

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 :

P1 = (A1,1 + A2,2 )(B1,1 + B2,2 )






P2 = (A2,1 + A2,2 )B1,1






P = A1,1 (B1,2 − B2,2 )

 3



P4 = A2,2 (B2,1 − B1,1 )


P5 = (A1,1 + A1,2 )B2,2









 P6 = (A2,1 − A1,1 )(B1,1 + B1,2 )


P7 = (A1,2 − A2,2 )(B2,1 + B2,2 )

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.

Svartz Page 71/187


5.4. CALCUL DE LA PAIRE DE POINTS LES PLUS PROCHES DANS UN NUAGE DE POINTSLycée Masséna

Algorithme de Strassen : analyse

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.

5.4.1 Approche naïve


Il y a n2 = n(n−1)

2 = O(n2 ) couples de points distincts dans le nuage : on peut donc tous les examiner en conservant
le couple le plus proche. On obtient donc la solution au problème avec une complexité O(n2 ).

5.4.2 Approche « diviser pour régner »


Avant de discuter d’un algorithme efficace, regardons déja ce que l’on peut faire avec comme nuage de points un
sous-ensemble de la droite réelle.

Intermède : approche en dimension 1

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.

Algorithme efficace : partie théorique

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.

Svartz Page 72/187


CHAPITRE 5. ALGORITHMES « DIVISER POUR RÉGNER » Lycée Masséna

— Si d est la distance minimale entre deux points y


de la partie gauche et deux points de la partie x=c
droite, alors deux points du nuage à distance
inférieure à d sont situés dans la bande d’abs-
cisse [c − d, c + d], où c est l’abscisse de la droite y = yM + d
centrale ;
— Soit M (xM , yM ) un point de cette bande. Dans
le rectangle délimité par c − d ≤ x ≤ c + d et y = yM
yM ≤ y ≤ yM + d se trouvent 8 carrés de coté M
d/2 (voir figure ci-contre). La distance maxi-
male entre 2 points d’unppetit carré est la lon-

gueur de la diagonale : d2 /4 + d2 /4 = d/ 2. d
Il ne peut y avoir qu’un seul point du nuage
par petit carré au maximum. Donc il suffit de
calculer la distance entre M et les sept points
suivants, si les points de la bande sont triés par x
ordonnée croissante.
On va donc pouvoir considérer tous les points M de la bande un par un. Pour chacun, on calcule la distance avec
les sept points suivants de la bande (par ordonnée croissante). Ainsi l’analyse de la bande se fait en temps O(n). La
complexité totale de l’algorithme est alors :

C(n) = C(bn/2c) + C(dn/2e) + O(n) = O(n log n)

Encore faut-il avoir la structure de donnée adéquate, ce dont on discute dans la sous-section suivante.

Algorithme efficace : idées pour l’implémentation pratique


Résumons les idées de l’algorithme :
— pour séparer les points du nuage autour d’un axe vertical, il est commode que les points soient triés par abscisse
croissante ;
— pour examiner les points de la bande dans l’ordre, il est commode que les points soient triés par ordonnée
croissante.
Plutôt que de procéder à de multiples tris à chaque appel récursif 8 , il vaut mieux introduire de la redondance dans la
manière de stocker les points du nuage : on duplique initialement le nuage sous la forme de deux tableaux, l’un trié
par abscisse croissante, l’autre par ordonnée croissante 9 . Ce précalcul a un coût O(n log n) avec le tri fusion, qui ne
sera appelé que deux fois en tout.
Notre fonction récursive prendra en entrée ces deux tableaux. A chaque étape, avant les appels récursifs, il faut :
— couper le tableau trié sur l’abscisse en deux ;
— en notant c l’abscisse qui sépare en deux les points du nuage, il faut répartir en deux les éléments du tableau
trié sur l’ordonnée (ceci se fait en O(n)).
Une fois les appels récursifs effectués et la distance d calculée :
— on extrait les points de la bande [c − d, c + d] à partir du tableau trié sur l’ordonnée ;
— on parcourt la bande en temps O(n).

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.

Svartz Page 73/187


5.4. CALCUL DE LA PAIRE DE POINTS LES PLUS PROCHES DANS UN NUAGE DE POINTSLycée Masséna

Svartz Page 74/187


CHAPITRE 6. INTRODUCTION À LA PROGRAMMATION DYNAMIQUE Lycée Masséna

Chapitre 6

Introduction à la programmation dynamique

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

Ce problème sera résolu dans ce chapitre.


— On appelle sous-séquence commune à deux chaînes de caractères s et t une chaîne x dont les caractères appa-
raîssent dans le même ordre dans s et dans t (avec possiblement des caractères intercalés). Quelle est la (une)
plus longue sous-séquence commune à « arythmie » et « rhomboédrique » ?
— etc...

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.

1. qui n’est pas une méthode de programmation...


2. Naturellement le poids d’un chemin est la somme des poids des arcs qui composent ce chemin.

Svartz Page 75/187


6.2. UN EXEMPLE COMPLET : CHEMIN DE POIDS MAXIMAL DANS UNE MATRICE Lycée Masséna

6.2 Un exemple complet : chemin de poids maximal dans une matrice


6.2.1 Le problème
On reprend le problème cité plus haut, trouver le chemin partant de la case en haut à gauche d’une matrice
A = (ai,j )0≤i<n,0≤j<m constituée d’entiers positifs, et aboutissant à la case en bas à droite en n’utilisant que les
déplacements → et ↓, dont le poids (somme des entiers rencontrés) est maximal.

6.2.2 Recherche exhaustive ?


On peut éventuellement chercher à examiner tous les chemins possibles, car ils sont en nombre fini. Dénombrons
les : pour construire un tel chemin, il suffit de savoir où placer les n − 1 déplacements ↓ parmi les n − 1 + m − 1
déplacements totaux. Ainsi il y a Nn,m = n+m−2

n−1 chemins possibles. Donnons un équivalent asymptotique de cette
quantité lorsque n = m, grâce à la formule de Stirling :
p
(2n − 2)2n−2 e−2(n−1) 2π(2n − 2) 22n−2 22n−2
 
2n − 2 (2n − 2)!
Nn,n = = ∼ = ∼ √
(n − 1)!2 n→+∞ 2(n − 1)2(n−1) e−2(n−1) π(n − 1)
p
n−1 π(n − 1) n→+∞ πn

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.

6.2.3 Solutions aux sous-problèmes


Considérons une solution à notre problème, c’est-à-dire un chemin c dans A de la case (0, 0) à la case (n − 1, m − 1),
dont le poids est maximal. Supposons que ce chemin passe par la case (i, j). Le chemin se décompose en deux morceaux
c c
(0, 0) 1 (i, j) 2 (n − 1, m − 1). Les deux chemins c1 et c2 sont des chemins de poids maximaux de la case (0, 0) à
(i, j) et de la case (i, j) à (n − 1, m − 1).
La technique de démonstration est classique, et à retenir : supposons que c1 ne soit pas optimal, il existe donc un
c01 c2
chemin c01 de poids strictement supérieur de la case (0, 0) à la case (i, j). Alors le chemin (0, 0) (i, j) (n − 1, m − 1)
est un chemin de poids strictement supérieur à c, ce qui est absurde. De même pour c2 .
Le problème d’optimisation d’un chemin de la case (0, 0) à une case (i, j) peut-être qualifié de sous-problème au
problème initial, car la matrice à considérer est plus petite (en effet comme on se restreint aux déplacements ↓ et →,
tout se passe sur une matrice de taille (i + 1) × (j + 1)).
Une solution au problème initial (sur la matrice n × m) donne une solution à de multiples sous-problèmes : c’est
une caractéristique que la programmation dynamique peut être utilisée pour résoudre le problème.

6.2.4 Une relation récursive pour le poids maximal d’un chemin


Notons (pi,j )0≤i<n,0≤j<m le poids maximal d’un chemin de (0, 0) à (i, j). Résoudre le problème consiste à trouver
un chemin de poids pn−1,m−1 de (0, 0) à (n − 1, m − 1). Concentrons nous d’abord sur le calcul de pn−1,m−1 , et d’une
manière générale de tous les (pi,j )0≤i<n,0≤j<m . Remarquons que les (pi,j )0≤i<n,0≤j<m satisfont la relation suivante :


 0 si i = j = 0
pi,j−1 si i = 0, et j > 0

pi,j = ai,j +

 pi−1,j si j = 0, et i > 0
max{pi−1,j , pi,j−1 } sinon.

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.

Svartz Page 76/187


CHAPITRE 6. INTRODUCTION À LA PROGRAMMATION DYNAMIQUE Lycée Masséna

6.2.5 Un calcul itératif des pi,j


Il est toutefois possible de calculer tous les coefficients pi,j très simplement en utilisant une matrice P , de même
taille que A, que l’on remplit en temps O(nm) à l’aide des coefficients de la matrice A en suivant la relation précédente.
Voici un code Caml :
let calcul_p a=
let n,m=[Link] a, [Link] a.(0) in
let p=Array.make_matrix n m a.(0).(0) in
for i=1 to n-1 do
p.(i).(0) <- p.(i-1).(0) + a.(i).(0)
done ;
for j=1 to m-1 do
p.(0).(j) <- p.(0).(j-1) + a.(0).(j)
done ;
for i=1 to n-1 do
for j=1 to m-1 do
p.(i).(j) <- a.(i).(j) + max p.(i-1).(j) p.(i).(j-1)
done
done ;
p
;;

L’appliquer à la matrice A de l’introduction donne la matrice P suivante :


   
2 39 12 49 47 18 22 19 2 41 53 102 149 167 189 208
 37 21 34 26 10 2 35 39   39 62 96 128 159 169 224 263 
   
A=  31 21 12 26 34 27 7 22   et P =  70 91 108 154 193 220 231 285 

 20 46 16 2 11 40 36 13   90 137 153 156 204 260 296 309 
18 30 32 37 28 24 9 6 108 167 199 236 264 288 305 315

6.2.6 Détermination d’une solution au problème initial


On sait maintenant calculer les pi,j dans un temps acceptable, il reste à déterminer un chemin de poids pn−1,m−1
dans la matrice A, de la case (0, 0) à la case (n − 1, m − 1).
Parmi plusieurs solutions, on propose la suivante : il suffit de remonter de la case (n − 1, m − 1) à la case (0, 0) dans
la matrice P . Depuis une case (i, j), on a le choix entre remonter à la case (i − 1, j) et remonter à la case (i, j − 1) : il
suffit de choisir la case (i0 , j 0 ) ∈ {(i − 1, j), (i, j − 1)} telle que pi0 ,j 0 est maximal : on obtient donc un chemin convenable
avec une complexité supplémentaire O(n + m).
Terminons cet exemple par une implémentation. On encode un chemin comme une liste de caractères « > » ou « v »
indiquant à chaque étape s’il faut se diriger sur la case de droite ou celle d’en dessous. Comme on remonte depuis la
case (n − 1, m − 1) insérer successivement les caractères dans une liste fait l’affaire. Voici le code :
let max_chemin a=
let n,m=[Link] a, [Link] a.(0) in
let p=calcul_p a in
let i=ref (n-1) and j=ref (m-1) and q=ref [] in
while !i>0 && !j>0 do
if p.( !i-1).( !j) > p.( !i).( !j-1) then
begin q:= "v":: !q ; decr i end
else
begin q:= ">":: !q ; decr j end
done ;
while !i>0 do
q:="v":: !q ; decr i
done ;
while !j>0 do
q:=">":: !q ; decr j
done ;
!q
;;

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"]

Svartz Page 77/187


6.3. PRINCIPES DE LA PROGRAMMATION DYNAMIQUE, ET VARIANTES Lycée Masséna

Autrement dit, un chemin de poids maximal est le suivant :


 
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

Le lecteur non convaincu vérifiera que ce chemin est de poids 315, ce qui correspond à p4,7 = pn−1,m−1 .

6.3 Principes de la programmation dynamique, et variantes


L’exemple précédent est typique d’une résolution de problème par programmation dynamique. Donnons un résumé
de la démarche, dans un cadre plus abstrait.

6.3.1 La démarche d’une résolution de problème par programmation dynamique


On se donne donc f : U → Z, et on cherche à déterminer x tel que f (x) = maxy∈U {f (y)} (la démarche est la même
si on cherche à minimiser f sur U). Dans la suite, on notera Mf,U la quantité maxy∈U {f (y)}.
Il y a quatre étapes dans la résolution d’un tel problème par programmation dynamique.
1. Identifier une sous-structure optimale : c’est un indice de l’application de la programmation dynamique. Il s’agit
de voir que si l’on connaît x ∈ U tel que f (x) = Mf,U , alors de x on déduit des solutions (xi )i∈I à des sous-
problèmes de la forme « trouver xi ∈ Ui , tel que fi (xi ) = maxy∈Ui {f (y)} = Mfi ,Ui ». Les problèmes associés
aux (fi , Ui ) doivent être plus simples à résoudre que le problème associé à (f, U). Dans l’exemple précédent, les
problèmes (fi , Ui ) étaient des déterminations de chemins de poids maximal dans des matrices plus petites.
2. Déduire de la sous-structure optimale une relation récursive permettant le calcul de Mf,U à partir de certains
Mfi ,Ui . Dans l’exemple précédent, on a exhibé une relation entre pn−1,m−1 , pn−2,m−1 et pn−1,m−2 .
Ce qui distingue une résolution par programmation dynamique d’un algorithme « diviser pour régner » est le fait que
les calculs des différents Mfi ,Ui ne sont pas du tout indépendants : écrire un algorithme récursif calculant tel quel
Mf,U mène en général à une solution très coûteuse, ce qui peut être résumé au travers du schéma suivant, calqué sur
le problème étudié précédemment :

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.

6.3.2 Une parenthèse sur les problèmes de combinatoire


La technique évoquée ci-dessus pour résoudre un problème d’optimisation s’applique aussi pour la résolution de
certains problèmes de combinatoire. Dans ce cas, on cherche plutôt à calculer la taille d’un certain ensemble U. La
démarche ressemble à celle ci-dessus :
3. Comment procéder ?
4. Dans le problème précédent, on a choisi de ne calculer un chemin convenable qu’après le calcul des pi,j , mais on aurait pu par exemple
stocker en parallèle des pi,j (dans une troisième matrice) le dernier déplacement à effectuer pour arriver en (i, j) en suivant un chemin
optimal.

Svartz Page 78/187


CHAPITRE 6. INTRODUCTION À LA PROGRAMMATION DYNAMIQUE Lycée Masséna

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.

Nombre de chemins sur un quadrillage


On considère un quadrillage de taille n × m, comme celui-ci dessous :

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
...

Svartz Page 79/187


6.4. DEUX AUTRES EXEMPLES DE RÉSOLUTION PAR PROGRAMMATION DYNAMIQUE Lycée Masséna

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.

6.3.3 Algorithmes « glouton »


Retour sur le problème du chemin maximal
Revenons au problème de trouver un chemin de poids maximal dans une matrice, qui n’utilise que les directions
→ et ↓. On a vu que la relation satisfaite par pi,j , poids d’un chemin maximal de (0, 0) à (i, j), était pi,j = ai,j +
max{pi−1,j , pi,j−1 }. Faisons un choix (localement optimal), et décidons de choisir (i0 , j 0 ) ∈ {(i − 1, j), (i, j − 1)} tel que
ai0 ,j 0 est maximal. En faisant systématiquement ce choix (le choix glouton) à chaque étape depuis la case (n − 1, m − 1)
jusqu’à la case (0, 0), on construit directement un unique chemin. La figure suivante rappelle le chemin de poids
maximal (315) trouvé grâce à la programmation dynamique, et le chemin fourni par le choix glouton.
   
2 39 12 49 47 18 22 19 2 39 12 49 47 18 22 19
 37 21 34 26 10 2 35 39   37 21 34 26 10 2 35 39 
   
optimal :  31 21 12 26 34 27 7 22 
 glouton :  31 21 12 26 34 27 7 22 
 
 20 46 16 2 11 40 36 13   20 46 16 2 11 40 36 13 
18 30 32 37 28 24 9 6 18 30 32 37 28 24 9 6
On vérifie que le chemin donné par l’algorithme glouton a un poids de 304, ce qui n’est pas optimal (mais pas loin !).

Principe des algorithmes glouton


Un algorithme glouton pour résoudre un problème d’optimisation suit les mêmes principes que la résolution par
programmation dynamique, néanmoins le point (3) diffère : au lieu d’utiliser un tableau pour calculer successivement
tous les Mfi ,Ui (qui correspondent aux pi,j dans le problème du chemin maximal dans une matrice), l’algorithme
glouton fait un choix local pour ramener le problème à un problème plus simple. Le choix effectué est localement
optimal (par exemple se diriger vers la case voisine ayant la plus grande valeur). Visuellement, on peut représenter les
choix faits par un algorithme glouton par le schéma ci-dessous, à droite.

Figure 6.1 – Comparaison des stratégies dynamique et gloutonne

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).

6.4 Deux autres exemples de résolution par programmation dynamique


6.4.1 Le problème de la sous séquence commune
Ce problème, déja évoqué dans l’introduction, consiste à trouver un mot x qui est une sous-séquence commune
maximale à deux mots s et t. Par exemple, une sous-séquence commune maximale à « arythmie » et « rhomboédrique »
7. C’est l’algorihtme de Dijkstra.

Svartz Page 80/187


CHAPITRE 6. INTRODUCTION À LA PROGRAMMATION DYNAMIQUE Lycée Masséna

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.

Sous-structure optimale. Notons n et m les longueurs de s et t. Pour 0 ≤ i ≤ n et 0 ≤ j ≤ m, on note `i,j la


longueur d’une plus longue sous-séquence commune aux préfixes de tailles i et j de s et t. Ce qui nous intéresse est
`n,m , et une sous-séquence associée. Supposons que l’on connaisse une sous-séquence commune x de longueur `n,m ,
supposé strictement positif.
— si les derniers caractères de s et t sont les mêmes, alors x termine par ce caractère (sinon on pourrait le rajouter !).
Mais alors x privé de son dernier caractère est une sous séquence commune à s et t tous deux privés de leur
dernier caractère (notés s0 et t0 dans la suite), et c’est même une plus longue sous-séquence commune à ces deux
mots (l’argument est classique : si ce n’était pas le cas, on pourrait trouver une sous-séquence commune à s et
t plus longue que x en rajoutant le dernier caractère commun à s et t à une sous-séquence commune maximale
de s0 et t0 ).
— si les derniers caractères de s et t diffèrent, alors x est une sous-séquence commune à s et t0 ou à s0 et t (voire
au deux), et c’est même une plus longue sous-séquence commune de manière évidente.
Nous avons exhibé une sous-structure optimale !

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.

Svartz Page 81/187


6.4. DEUX AUTRES EXEMPLES DE RÉSOLUTION PAR PROGRAMMATION DYNAMIQUE Lycée Masséna

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 :

#plssc "arythmie" "rhomboedrique" ;;


- : string = "rhmie"

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).

6.4.2 Plus grand carré de zéros dans une matrice binaire


Pour ce dernier exemple, on se donne une matrice A = (ai,j )0≤i<n,0≤j<m de taille n × m, constituée de zéros et de
uns, comme la suivante :
 
1 0 0 1 1 0 1 1 0
 0 1 1 1 0 1 1 0 0 
 
 0 0 0 1 0 1 0 0 0 
A=  0 1 1 1 0 0 0 0 0 

 
 0 0 0 0 0 1 0 0 0 
1 1 0 0 1 0 1 1 0
On cherche la taille du plus grand carré de zéros dans cette matrice. Pour l’exemple ci-dessus, la réponse cherchée est
trois.

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.

Svartz Page 82/187


CHAPITRE 7. INTRODUCTION AUX ARBRES Lycée Masséna

Chapitre 7

Introduction aux arbres

7.1 Les arbres comme objets mathématiques


7.1.1 Définitions
Définition 7.1. Un arbre A est un ensemble non vide muni d’une relation binaire ≺ vérifiant :
— ∃!r ∈ A ∀x ∈ A ¬(r ≺ x). L’élément r s’appelle la racine de l’arbre.
— ∀x ∈ A\{r} ∃!t ∈ A x ≺ y. On dit que y est le parent (ou père) de x, et x est un fils de y.
— ∀x ∈ A\{r} ∃n > 0 ∃(x1 , . . . , xn ) ∈ An x ≺ x1 ≺ x2 ≺ · · · ≺ xn = r.
La relation binaire x ≺ y signifie que x est un enfant de y. Les conditions peuvent se résumer ainsi : mis à part la
racine r, chaque élément a un unique parent, et en suivant ces liens de parenté on aboutit à la racine. Visuellement, on
représente un arbre avec des nœuds reliés par des arêtes, la racine de l’arbre étant situé tout en haut 1 Par exemple :

Figure 7.1 – Un arbre

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.

Svartz Page 83/187


7.1. LES ARBRES COMME OBJETS MATHÉMATIQUES Lycée Masséna

Figure 7.2 – Un arbre binaire, et un arbre binaire entier

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.

7.1.2 Un peu de dénombrement


Inégalités entre hauteur et nombre de nœuds
On donne ici des encadrements faisant intervenir la hauteur et le nombre de nœuds d’un arbre, en fonction de
l’arité maximale des nœuds.
Proposition 7.7. Pour A un arbre de hauteur h dont les nœuds sont d’arité au plus a, le nombre n de nœuds de
l’arbre vérifie si a > 1 :
ah+1 − 1
h+1≤n≤
a−1
Démonstration. — Considérons un nœud à profondeur maximale h. Sur le chemin de ce nœud à la racine, il y a
h + 1 nœuds, d’où n ≥ h + 1. Avec a l’arité maximale, on montre aisément par récurrence qu’il y a au plus ap
Ph h+1
nœuds à profondeur p. L’autre inégalité s’obtient donc par somme : p=0 ap = a a−1−1 .

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.

Feuilles et nœuds internes dans un arbre binaire


Proposition 7.11. Un arbre binaire entier ayant p nœuds internes possède p + 1 feuilles.
Démonstration. La démonstration se fait par récurrence forte (sur p par exemple).
— Si p = 0, l’arbre a une seule feuille (sa racine) donc la relation est vérifiée.
— Sinon soit p > 0. Considérons un arbre A ayant p > 0 nœuds internes. La racine étant un nœud interne, notons
alors ng et nd le nombre de nœuds internes des sous-arbres enracinés en les deux fils de la racine. Ces arbres sont
également binaires entiers, et vérifient ng < p et nd < p, donc par hypothèse de récurrence, ils ont respectivement
ng + 1 et nd + 1 feuilles. Dans l’arbre A, il y a donc ng + nd + 1 nœuds internes et (ng + nd + 1) + 1 feuilles,
donc la propriété est vraie pour un arbre de hauteur p ;
— Par principe de récurrence, la propriété est démontrée.

Corollaire 7.12. Dans un arbre binaire à p nœuds internes, il y a au plus p + 1 feuilles.


Démonstration. Rajouter un fils (une feuille) aux nœuds d’arité 1 transforme l’arbre en arbre binaire entier, sans
changer le nombre de nœuds internes.

Svartz Page 84/187


CHAPITRE 7. INTRODUCTION AUX ARBRES Lycée Masséna

7.2 Les arbres en Caml


En informatique, les arbres sont utilisés pour stocker de l’information : à chaque nœud est attaché une étiquette, qui
peut être un entier, une chaîne de caractères, voire même un couple (voir chapitre suivant). De plus, les fils d’un nœud
sont en général ordonnés : par exemple pour un arbre binaire entier, on parlera du fils gauche et du fils droit d’un nœud
interne. On propose dans cette section une implémentation persistante des arbres : comme pour les listes chaînées,
les fonctions sur la structure d’arbre renverront de nouveaux arbres plutôt que de les modifier (une implémentation
impérative sera vue au chapitre suivant).

7.2.1 Arbres généraux


Pour des arbres généraux, on peut représenter un nœud par la liste de ses fils, qui sont eux même des arbres. On
distinguera alors une feuille et un nœud interne suivant si la liste des fils est vide ou non. Pour que les nœuds de l’arbre
puissent porter des étiquettes d’un certain type, on peut définir le type (récursif) suivant :

type 'a arbre = N of 'a * 'a arbre list;;

Voici un exemple d’arbre à étiquettes entières :

12 5 8 7

17 1 14 4 3

0 6 4

Figure 7.3 – Un arbre à étiquettes entières

En Caml cet arbre est implémenté comme suit :

#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 :

let rec hauteur a=match a with


| N(_,q) -> 1+max_h q
and max_h q=match q with
| [] -> -1
| x::p -> max (hauteur x) (max_h p)
;;

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;;

Svartz Page 85/187


7.2. LES ARBRES EN CAML Lycée Masséna

7.2.2 Arbres binaires entiers


En informatique, les arbres sont souvent des arbres binaires (entiers ou non). On va donc utiliser une implémentation
un peu moins générale que celle de la section précédente. Pour les arbres binaires entiers, un arbre (informatique) est :
— soit une feuille ;
— soit la donnée d’une étiquette, et de deux arbres (ses sous-arbres gauche et droit).
En suivant cette description, on obtient le type suivant, où l’on distingue les étiquettes des feuilles et des nœuds
internes.
type ('a, 'b) arbre = F of 'a | N of 'b*('a, 'b) arbre * ('a, 'b) arbre;;

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

Figure 7.4 – L’arbre associé à l’expression arithmétique (3 + 4) × (5 − (2 × 6)).

En Caml on représente l’expression de la figure 7.4 comme :

type op = Plus | Moins | Fois | Div | Mod ;;


let expr = N (Fois, N (Plus, F 3, F 4), N (Moins, F 5, N (Fois, F 2, F 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 :

let rec evalue e=match e with


| F x -> x
| N (op, a, b) -> (traduit op) (evalue a) (evalue b)
;;

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

7.2.3 Arbres binaires


On propose une implémentation des arbres binaires qui sera reprise dans le chapitre suivant, pour l’implémentation
des arbres binaires de recherche. Elle est très proche de l’implémentation précédente des arbres binaires entiers : en
effet, si on considère un arbre binaire, et qu’en chaque nœud x on fait pousser 2 − a fils où a est l’arité de x, on obtient
un arbre binaire entier. On note « Vide » les nœuds que l’on fait pousser, de sorte que les feuilles du nouvel arbre sont
toutes « Vide ».
Avec cette représentation, un arbre binaire est :
— soit vide ;
— soit la donnée d’une étiquette, et deux deux sous-arbres binaires.
Ceci mène à la définition du type suivant :

type 'a arbre = Vide | N of 'a * 'a arbre * 'a arbre ;;

Svartz Page 86/187


CHAPITRE 7. INTRODUCTION AUX ARBRES Lycée Masséna

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é.

Voici l’implémentation de l’arbre correspondant à l’exemple de la figure 7.5 :

let ex_ab = N (14,


N(7, N(3, N(1, Vide, Vide), Vide), N(10, Vide, Vide)),
N(20, N(16, Vide, N(18, Vide, Vide)), Vide)) ;;

Donnons pour terminer une fonction permettant de calculer la hauteur 2 d’un tel arbre :

let rec hauteur a=match a with


| Vide -> -1
| N(_,g,d) -> 1 + max (hauteur g) (hauteur d)
;;

Par exemple :

#hauteur ex_ab ;;
- : int = 3

7.3 Parcours d’arbres binaires entiers


On veut énumérer les nœuds d’un arbre binaire entier, de même type que défini en sous-section 7.2.2. On va stocker
le résultat dans une liste. On introduit un type pour stocker dans une même liste les étiquettes des nœuds internes et
des feuilles :

type ('a, 'b) etiq = A of 'a | B of 'b ;;

7.3.1 Parcours en profondeur


Le parcours en profondeur d’un arbre consiste à s’enfoncer le plus possible dans l’arbre avant de revenir en arrière
explorer les autres branches. Pour un arbre binaire entier de racine r, et de sous-arbres gauche et droit g et d, il y a
trois choix naturels :
— racine r, enumération de g, énumération de d : on parle de parcours préfixe ;
— enumération de g, racine r, énumération de d : on parle de parcours infixe ;
— enumération de g, énumération de d, racine r : on parle de parcours postfixe (ou suffixe).
Voici une écriture du parcours préfixe :

let rec prefixe a=match a with


| F x -> [A x]
| N(x,g,d) -> (B x)::(prefixe g)@(prefixe d)
;;

Testons avec l’expression arithmétique de la figure 7.4.


2. On convienr que l’arbre « Vide » a pour hauteur −1 : ceci est cohérent avec le fait que les feuilles « Vide » ne font pas vraiment
partie de l’arbre.

Svartz Page 87/187


7.3. PARCOURS D’ARBRES BINAIRES ENTIERS Lycée Masséna

# prefixe expr ;;
- : (int, op) etiq list =
[B Fois; B Plus; A 3; A 4; B Moins; A 5; B Fois; A 2; A 6]

On peut de même écrire des fonctions infixe et postfixe :

# 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.

Démonstration. Le lemme se démontre par récurrence sur la longueur de l’énumération.


— pour une énumération de longueur 1 (correspondant à une unique feuille), c’est immédiat ;
— sinon, l’énumération est constituée de l’énumération du sous-arbre gauche (par récurrence, s est toujours stric-
tement positif et termine à 1), celle du sous-arbre droit (s reste supérieur à 1 et termine à 2), et enfin la racine
(s termine à 1).
— Par principe de récurrence, le lemme est démontrée.

Preuve de la proposition 7.13. On procède par récurrence sur la longueur de l’énumération.


— Une énumération de taille 1 correspond à un arbre à une unique feuille, l’unicité est donc évidente.
— Donnons nous maintenant une énumération postfixe d’un arbre binaire entier, de taille strictement supérieure
à 1, et supposons la propriété démontrée pour des énumérations plus petites. Par nature de l’énumération, se
trouve d’abord toute l’énumération du sous-arbre gauche, puis celle du sous-arbre droit, et enfin la racine de
l’arbre. Pour pouvoir appliquer l’hypothèse de récurrence et terminer la preuve, il suffit de savoir où se situe la
frontière entre les énumérations des sous-arbres gauche et droit. Or, on déduit du lemme que cette frontière se
situe juste après que le compteur s du lemme ait pris la valeur 1 pour la dernière fois (avant la racine) : on peut
donc reconstruire l’arbre par hypothèse de récurrence.
— Par principe de récurrence, la propriété est démontrée.

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 :

#evalue_postfixe (postfixe expr) ;;


- : int = -49

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 !

Svartz Page 88/187


CHAPITRE 7. INTRODUCTION AUX ARBRES Lycée Masséna

× −

+ − + ×

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.

7.3.2 Parcours en largeur


Contrairement au parcours en profondeur, le parcours en largeur liste les nœuds par profondeur croissante. Par
convention, les nœuds situés à gauche sont énumérés en premier. L’énumération de ce parcours est un peu plus délicate
à obtenir qu’une énumération d’un parcours en profondeur. On commence par écrire deux fonctions qui prennent en
entrée une liste d’arbres, et renvoient respectivement l’énumération de leurs racines, et la liste de leurs sous-arbres.

let rec racines q=match q with


| [] -> []
| (F x)::p -> (A x)::(racines p)
| N(x,_,_)::p -> (B x)::(racines p)
;;

let rec ss_arbres q=match q with


| [] -> []
| (F _)::p -> ss_arbres p
| N(_,g,d)::p -> g::d::ss_arbres p
;;

On peut maintenant écrire une fonction de parcours en largeur :

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.

Svartz Page 89/187


7.3. PARCOURS D’ARBRES BINAIRES ENTIERS Lycée Masséna

Svartz Page 90/187


Lycée Masséna

Deuxième partie

Programme de deuxième année

Svartz Page 91/187


CHAPITRE 8. STRUCTURES À L’AIDE D’ARBRES : FILE DE PRIORITÉ ET DICTIONNAIRELycée Masséna

Chapitre 8

Structures à l’aide d’arbres : file de priorité


et dictionnaire

8.1 Rappel sur les structures abstraites


On rappelle qu’une structure abstraite est la donnée d’un type et des opérations que l’on peut effectuer dessus : la
définition d’une structure abstraite est indépendante d’une réalisation (implémentation) concrète. On va implémenter
dans ce chapitre deux structures abstraites :
— la structure de file de priorité (abrégé FP) : elle doit gérer des couples de la forme (p, élément). Les premières
composantes p sont à valeurs dans un ensemble ordonné (en pratique ce sont des entiers, qui indiquent la priorité
de l’élément). Les éléments présents dans la FP sont tous distincts, par contre ce n’est pas nécessairement le
cas des composantes p. Il y a une distinction à faire, suivant si les éléments prioritaires sont ceux ayant un p
maximal (on parle de FP max) ou minimal (FP min). La structure doit garantir les opérations suivantes :
— création d’une FP vide ;
— test d’égalité d’une FP au vide ;
— insertion d’un nouvel élément, avec sa priorité ;
— retrait de l’élément de plus grande priorité ;
— modification de la priorité d’un couple (augmentation ou diminution).
— la structure de dictionnaire : elle doit gérer des couples (clé, élément). Les clés sont à valeurs dans un ensemble or-
donné (entiers, chaînes de caractères), par contre elles sont maintenant supposées toutes distinctes, contrairement
aux éléments qui sont quelconques. La structure doit garantir les opérations :
— création d’un dictionnaire vide ;
— test d’égalité d’un dictionnaire au vide ;
— recherche de la présence d’un couple (clé, élément) à partir de la clé ;
— insertion d’un couple (clé, élément), si la clé n’est pas déja présente ;
— suppression d’un couple (clé, élément) à partir de la clé.
— remplacement de l’élément d’un couple (clé, élément) par un autre de même clé.
Voici quelques exemples d’utilisation des deux structures présentées ici :
— une file de priorité peut être utilisée pour gérer un agenda (les taches les plus urgentes à effectuer doivent être
effectuées en premier). On retrouve cette structure dans les imprimantes de bureau, ou dans la gestion des proces-
sus à effectuer sur un ordinateur. En algorithmique, on utilisera une file de priorité (min) pour l’implémentation
efficace d’un algorithme de recherche de plus courts chemins dans un graphe pondéré (algorithme de Dijkstra).
— un dictionnaire peut être utilisé pour gérer un dictionnaire (usuel) ! Plus généralement, ils sont très utilisés, par
exemple pour gérer des listes d’utilisateurs d’un site web. En algorithmique, on peut par exemple se servir d’un
dictionnaire pour implémenter la fonction de modification de la priorité d’un élément dans une file de priorité
(ce qu’on verra dans ce chapitre).
Une fois donnée une structure abstraite, le but du jeu est d’en donner une implémentation efficace, ce que l’on va
faire maintenant.

Svartz Page 93/187


8.2. TAS ET FILE DE PRIORITÉ Lycée Masséna

8.2 Tas et file de priorité


8.2.1 Une file de priorité à l’aide d’une liste ?
Une première idée pour implémenter une FP-max (c’est pareil pour une FP-min) est d’utiliser une simple liste.

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.

8.2.2 La structure de tas


Dans la suite, on se concentre sur l’implémentation d’une FP-max, pour laquelle on va utiliser un tas-max (on
ferait usage de manière symétrique d’un tas-min pour une FP-min).

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.

Remarque 8.2. Deux remarques :


— pour un tas-min, remplacer « supérieure ou égale » par « inférieure ou égale » ;
— la propriété de tas-max implique que l’étiquette d’un nœud s est supérieure ou égale à celles de tous les nœuds
appartenant au sous-arbre enraciné en s. En particulier la racine de l’arbre a la plus grande étiquette.

18

15 16

14 8 3 5

11 9 4

Figure 8.1 – Un tas-max

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.

Svartz Page 94/187


CHAPITRE 8. STRUCTURES À L’AIDE D’ARBRES : FILE DE PRIORITÉ ET DICTIONNAIRELycée Masséna

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 !

8.2.3 Opérations sur un tas


Dans cette partie, on décrit de manière théorique comment maintenir la structure de tas-max lorsque l’on rajou-
te/supprime un élément, ou lorsqu’on modifie un nœud. Essentiellement, il y a deux mécanismes à décrire, dont les
autres vont découler. Il s’agit de rétablir la structure de tas lorsqu’on diminue ou augmente l’étiquette d’un nœud. On
suppose que l’échange des étiquettes contenues dans deux nœuds peut se faire en complexité constante dans le tas.

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) :

Algorithme 8.8 : monter_nœud


Entrées : Un (presque) tas-max t, un nœud s
si s n’est pas la racine et E(parent(s)) < E(s) alors
Échanger les étiquettes de s et parent(s) ;
monter_nœud(t, parent(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

Figure 8.2 – L’effet de monter_nœud sur le nœud d’étiquette (augmentée) 17

Svartz Page 95/187


8.2. TAS ET FILE DE PRIORITÉ Lycée Masséna

Algorithme 8.9 : descendre_nœud


Entrées : Un (presque) tas-max t, un sommet s
u ← s;
si s a un fils gauche g et E(g) > E(s) alors
u←g
si s a un fils gauche d et E(d) > E(u) alors
u←d
si u 6= s alors
Échanger les étiquettes de s et u ;
descendre_nœud(t, u)

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

Figure 8.3 – L’effet de descendre_nœud sur le nœud d’étiquette (diminuée) 7

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.

8.2.4 Stockage d’un arbre binaire complet à gauche dans un tableau

18

15 16

18 15 16 14 8 3 5 11 9 4
14 8 3 5

11 9 4

Figure 8.4 – Un tableau et l’arbre associé

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 :

Svartz Page 96/187


CHAPITRE 8. STRUCTURES À L’AIDE D’ARBRES : FILE DE PRIORITÉ ET DICTIONNAIRELycée Masséna

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.

Remarque 8.11. Il est assez courant lorsqu’on implémente un tas dans


 un tableau, de commencer à remplir le tableau
à partir de l’indice 1 en ignorant la première case, ce qui donne 2i , 2i et 2i + 1 dans la proposition précédente. On
ne le fera pas dans ce cours.

8.2.5 Implémentation de la structure de file de priorité max dans un tableau


Pour la réalisation d’une file de priorité, il est nécessaire d’utiliser un tableau qui n’est pas entièrement rempli,
pour laisser de la place aux éléments que l’on va rajouter à la file.
On présente ici une implémentation où la taille du tableau est fixée une fois pour toute à la création 1 , et l’on
maintient une variable indiquant le nombre d’éléments du tableau qui font effectivement partie du tas : si cette
variable est n, les éléments d’indice 0 à n − 1 sont ceux du tas, les suivants pouvant être quelconques.

On utilise donc le type suivant :

type 'a fp = {mutable n: int ; tab: 'a array} ;;

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 ;;

let creer_fp taille x = {n=0 ; tab=[Link] taille x} ;;

let est_vide_fp f = f.n=0 ;;

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.

Svartz Page 97/187


8.2. TAS ET FILE DE PRIORITÉ Lycée Masséna

let rec monter_noeud t i=


if i<>0 && t.(pere i)<t.(i) then begin
echanger t i (pere i) ;
monter_noeud t (pere i)
end
;;

let rec descendre_noeud t n i=


let j=ref i in
if fg i < n && t.(fg i)>t.(i) then j:=(fg i) ;
if fd i < n && t.(fd i)>t.( !j) then j:=(fd i) ;
if i<> !j then begin
echanger t i !j ;
descendre_noeud t n !j
end
;;

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.

8.2.7 Intermède : le tri par tas


Les fonctions précédemment écrites suffisent à implémenter le fameux « tri par tas » : pour trier un tableau, on
crée une file de priorité dans laquelle on rajoute les éléments du tableau un par un, puis on supprime les éléments de
la file un par un, ce qui fournit les éléments du tableau dans l’ordre décroissant, que l’on peut donc stocker à la bonne
place.
let tri_par_tas t=
let n=[Link] t in
let f=creer_fp n t.(0) in
for i=0 to n-1 do
enfiler_fp f t.(i)
done ;
for i=n-1 downto 0 do
t.(i) <- supprimer_max_fp f
done ;;

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).

Svartz Page 98/187


CHAPITRE 8. STRUCTURES À L’AIDE D’ARBRES : FILE DE PRIORITÉ ET DICTIONNAIRELycée Masséna

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.

8.2.8 Modification d’une clé


Dans une file de priorité, les éléments stockés sont des couples (clé, élément). L’une des opérations possibles de la
structure est la modification de la clé associée à un élément présent dans la file. Il faut donc être capable de retrouver
le nœud du tas à partir de l’élément. On indique deux solutions.
• Un cas qui se produit souvent est celui où les éléments sont à valeurs dans un ensemble de la forme [[0, B −1]], avec
B un entier pas trop grand. On peut alors utiliser un tableau de taille B pour stocker les positions des éléments
présents dans la file de priorité dans le tableau associé au tas. Lorsqu’on utilise la fonction echanger dans une
des fonctions ci-dessus, il ne faut pas oublier de répercuter le changement sur ce tableau des positions. De même,
il faut gérer l’insertion et la suppression d’un élément dans la file de priorité avec ce tableau. La complexité des
opérations de file de priorité restent alors logarithmique en le nombre d’éléments, de même que la modification
de la priorité : il suffit de monter ou descendre l’élément dont on a modifié la priorité. Un exemple où cette
approche est très efficace est celle de l’algorithme de Djikstra sur un graphe dont les sommets sont numérotés
de 0 à N − 1.
• Plus généralement, on peut utiliser une structure de dictionnaire dans laquelle on stocke des couples (élément,
position dans le tableau), ce qui amène à la section suivante ! La complexité des opérations de file de priorité s’en
trouve modifiée suivant la structure choisie, signalons qu’avec une table de hachage elle est quasiment inchangée 2

8.3 Arbres binaires de recherche, Arbres AVL


8.3.1 Implémentation d’une structure de dictionnaire avec une liste chaînée
Pour les mêmes raisons que pour une file de priorité, utiliser une structure de liste chaînée pour implémenter une
structure de dictionnaire n’est pas efficace du point de vue de la complexité : il faut stocker des couples (clé, élément)
où les clés sont des éléments distincts, et rechercher si une clé est présente se fait en temps linéaire en le nombre
d’éléments présents dans le pire cas. On va, là aussi, proposer une structure efficace à l’aide d’arbres, plus précisément
d’arbres binaires de recherche.

8.3.2 Structure d’arbre binaire de recherche


Définition 8.13. un arbre binaire de recherche (abrégé ABR) est un arbre binaire éventuellement vide dont les nœuds
sont des couples (clé, valeur), tel que
— deux nœuds n’ont jamais la même clef ;
— les clefs sont à valeur dans un ensemble totalement ordonné (on peut ordonner les clefs) ;
— pour tout nœud de l’arbre, les clés du sous arbre gauche sont strictement inférieures à celle du nœud, celles du
sous arbre droit sont strictement supérieures.
Deux exemples d’ABR avec comme ensemble de clés {canari, castor, chameau, chat, cheval, chien, chouette}
(ordonné pour l’ordre naturel sur les chaînes de caractères) sont donnés figure 8.5. On n’y a pas mis les valeurs
associées, qui pourraient être les définitions de ces mots dans la langue française.

cheval chat

chameau chouette canari chien

canari chat chien chameau cheval chouette

castor castor

Figure 8.5 – Deux exemples d’ABR avec un même ensemble de clés

La condition sur les clés d’un ABR implique la proposition suivante.


2. Bien implémentée, une table de hachage effectue ses opérations en temps constant amorti, sous des hypothèses naturelles de répartition
« aléatoire » des clés de hachage.

Svartz Page 99/187


8.3. ARBRES BINAIRES DE RECHERCHE, ARBRES AVL Lycée Masséna

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.

Démonstration. La propriété précédente fournit l’encadrement log2 (n + 1) − 1 ≤ h ≤ n − 1. Or h ≥ log2 (n + 1) − 1 >


log2 (n) − 1 ≥ blog2 (n)c − 1. Donc h ≥ blog2 (n)c.

8.3.3 Implémentation des ABR en Caml


On choisit une implémentation persistante des ABR, identique à celle classique des arbres binaires. Contrairement
à l’implémentation des tas de la section précédente, les fonctions qui travailleront sur les arbres ne modifieront pas
la structure mais renverront de nouveaux arbres. En pratique, on implémente les ABR comme des arbres binaires
entiers (tout nœud interne a 2 fils), les feuilles correspondent à un arbre vide (remarque : on oublie ces nœuds dans la
définition de la hauteur 3 ).

type 'a abr = Vide | N of 'a abr * 'a * 'a abr;;

On peut facilement calculer la hauteur :

let rec hauteur a=match a with


| Vide -> -1
| N(g,_,d) -> 1 + max (hauteur g) (hauteur d)
;;

8.3.4 Implémentation de la structure de dictionnaire avec des ABR


En Caml, on pourrait représenter les nœuds comme :

type ('a, 'b) noeud = {cle : 'a; mutable valeur: 'b};;

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 :

let creer_abr () = Vide ;;


let est_vide_abr a = a=Vide ;;

3. Attention, ce n’est pas forcément standard.

Svartz Page 100/187


CHAPITRE 8. STRUCTURES À L’AIDE D’ARBRES : FILE DE PRIORITÉ ET DICTIONNAIRELycée Masséna

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.

let rec rechercher a x=match a with


| Vide -> false
| N(g,y,_) when y>x -> rechercher g x
| N(_,y,d) when y<x -> rechercher d x
| _ -> true
;;

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

let rec inserer a x=match a with


| Vide -> N(Vide,x,Vide)
| N(g,y,d) when y>x -> N(inserer g x, y, d)
| N(g,y,d) when y<x -> N(g, y, inserer d x)
| _ -> a
;;

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.

Écrire les fonctions est alors facile :


let rec fusion a1 a2=match a1,a2 with
| Vide,_ -> a2
| _,Vide -> a1
| N(g1,x1,d1), N(g2,x2,d2) -> N(g1,x1,N(fusion d1 g2,x2,d2))
;;

let rec supprimer a x=match a with


| Vide -> Vide
| N(g,y,d) when y>x -> N(supprimer g x, y, d)
| N(g,y,d) when y<x -> N(g, y, supprimer d x)
| N(g,y,d) -> fusion g d
;;

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.

Svartz Page 101/187


8.3. ARBRES BINAIRES DE RECHERCHE, ARBRES AVL Lycée Masséna

• 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
;;

let rec supprimer a x=match a with


| Vide -> Vide
| N(g,y,d) when y>x -> N(supprimer g x, y, d)
| N(g,y,d) when y<x -> N(g, y, supprimer d x)
| N(Vide,y,d) -> d
| N(g,y,d) -> let z=max_abr g in N(supprimer g z, z, 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.

8.3.5 Arbres AVL


Définition 8.20. Un arbre AVL 5 est un ABR tel que pour tout nœud, ses sous-arbres gauche et droit aient même
hauteur, à 1 près.
Cette condition est suffisante pour que l’arbre soit approximativement équilibré, comme le montre le théorème
suivant :
Théorème 8.21. La hauteur h d’un arbre AVL à n nœuds vérifie h = O(log n).
Démonstration. Il suffit de voir qu’un arbre AVL a toujours un grand nombre de nœuds, relativement à sa hauteur.
Notons Nh le nombre de nœuds minimal d’un arbre AVL de hauteur h. On a N0 = 1, N1 = 2 et Nh = Nh−1 +Nh−2 +1.
La suite√ (Nh + 1) vérifie donc la même relation que la suite de Fibonacci. D’où Nh = Cϕh + D(−1/ϕ)h − 1, avec
ϕ = 1+2 5 et C et D deux constantes. Asymptotiquement, Nh ∼ Cϕh . Par suite un arbre AVL à n nœuds possède une
hauteur majorée par logϕ (n) (à une constante additive près), d’où le résultat.

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).

8.3.6 Rotations et maintien de la structure d’arbre AVL


Les opérations de recherche, insertion, suppression dans un arbre AVL à n nœuds se font donc en temps O(log n).
Mais il faut modifier les fonctions d’insertion et de suppression pour que la structure d’arbre AVL soit maintenue. On
va procéder en utilisant des rotations sur l’arbre, expliquées à la figure 8.7. On remarque que ces rotations maintiennent
bien la structure d’ABR.
En fait, on va insérer/supprimer les nœuds comme dans les ABR, mais on opérera un rééquilibrage éventuel « à
la remontée », en procédant par rotations. Lorsqu’on insère/supprime un nœud, il se peut que l’on introduise un
5. du nom de ses inventeurs : Georgii Adelson-Velsky et Evguenii Landis.

Svartz Page 102/187


CHAPITRE 8. STRUCTURES À L’AIDE D’ARBRES : FILE DE PRIORITÉ ET DICTIONNAIRELycée Masséna

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

h−2 α β h−2−p h−2−p β γ h−3

Figure 8.8 – Déséquilibre dans un arbre AVL, cas 1 : une rotation droite suffit.

8.3.7 Opérations sur les arbres AVL en Caml


On donne simplement l’idée de l’implémentation des opérations sur les AVL dans ce cours. On peut par exemple
utiliser le type suivant :
type 'a avl = Vide | N of int * 'a avl * 'a * 'a avl

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.

Svartz Page 103/187


8.3. ARBRES BINAIRES DE RECHERCHE, ARBRES AVL Lycée Masséna

x h x h

rotation gauche (sous-arbre)


h−1 y γ h−3 z h−1 γ h−3

h−3 α z h−2 h−2 y ε h−3−q

h−3−p δ ε h−3−q h−3 α δ h−3−p

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.

Svartz Page 104/187


CHAPITRE 9. PREUVES PAR INDUCTION Lycée Masséna

Chapitre 9

Preuves par induction

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 ∪ {+, ×, (, )}.

9.2 Définitions inductives


Une définition inductive définit une partie d’un certain ensemble E, comme la plus petite contenant un certain
sous-ensembles de base, et stable par application de certaines règles de construction. Avant de formaliser, une petite
remarque.

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.

9.2.1 Le théorème du point fixe


Définition 9.2 (Outils pour la définition inductive). Soit E un ensemble. On appelle :
— ensemble de base un certain sous-ensemble B ⊂ E ;
— règle une application partielle r : E n → E, avec n un entier strictement positif, appelé l’arité de r.

La définition inductive d’une partie de E repose sur le théorème suivant :

Théorème 9.3 (Théorème de point fixe). Soit E un ensemble. Considérons :


— B ⊂ E un ensemble de base ;
— R = {rj | j ∈ J} un ensemble de règles, avec rj : E nj → E. Ces règles sont appelées règles d’inférence.
Alors il existe un plus petit ensemble X ⊂ E, tel que :
— (B) : B ⊂ X ;
— (I) : pour tout j ∈ J, pour tout élément (x1 , . . . , xnj ) ∈ X nj appartenant à l’ensemble de définition de rj , on a
rj (x1 , . . . , xnj ) ∈ X.

Svartz Page 105/187


9.2. DÉFINITIONS INDUCTIVES Lycée Masséna

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.

9.2.2 Des exemples


Exemple 9.5 (Entiers naturels pairs). L’ensemble P des entiers naturels pairs est défini par :
— 0∈P
— x∈P ⇒x+2∈P
Comme on le voit, l’ensemble E n’a pas un rôle très important, on peut prendre N, Z, R, C...
Exemple 9.6 (Mots de Dyck). L’ensemble D des mots de Dyck sur l’alphabet {a, b} est défini par :
— le mot vide ε est dans D ;
— x, y ∈ D ⇒ axby ∈ D
Exemple 9.7 (Listes chaînées). Les listes chaînées sur un ensemble F sont très semblables à celles sur les entiers
naturels :
— la liste vide est une liste ;
— si x est un élément de F , et ` une liste, alors Cons(x, L) est une liste.
On peut définir plus proprement les listes comme un ensemble de mots sur l’alphabet constitué des éléments de F et
de {(, ), }, par exemple.
Exemple 9.8 (Arbres binaires). Soit F un ensemble. Les arbres binaires étiquetés par F sont définis ainsi :
— l’arbre vide est un arbre binaire ;
— si x est un élément de F , Ag et Ad deux arbres binaires, alors Noeud(Ag , x, Ad ) est un arbre binaire.
Exemple 9.9 (Arbres quelconques). L’ensemble des arbres (non étiquetés) peuvent être définis sur l’alphabet {(, )} ∪
{a0 , a1 , a2 , a3 , . . .} par :
— a0 est un arbre.
— Pour tout n > 0 et pour tout n-uplet t1 , . . . , ti d’arbres, an (t1 , . . . , tn ) est un arbre.
Visuellement, on représente avec la règle d’inférence un arbre dont la racine possède n sous-arbres qui sont dans l’ordre
t1 , . . . , tn . Le nombre de règles étant ici non dénombrable, cette construction théorique est intéressante, mais pas utile
pour une implémentation. En voici une autre.
Exemple 9.10 (Arbres et forêts). Les règles d’inférence sur les arbres et forêts (ensemble d’arbres) étiquetés par des
éléments d’un ensemble F sont :
— l’arbre vide Vide est un arbre binaire ;
— la foret vide [] est une forêt ;
— si A est un arbre et F une forêt, Cons(A, F) est une forêt.
— si F est une forêt et x un élément de F , Noeud(x, F) est un arbre.
Exemple 9.11 (Expressions arithmétiques sur les entiers). On note N l’ensemble des expressions sur l’alphabet
{0, . . . , 9} représentant les entiers naturels (essentiellement, à part 0, aucune chaîne ne commence par un 0). Cet
ensemble peut être construit inductivement, on laisse au lecteur le soin de décrire la construction. L’ensemble des
expressions arithmétiques A sur N peut être défini ainsi, sur l’alphabet {0, . . . , 9, +, −, ×, /, (, )} :
— N ⊂ A;
— pour tout op ∈ {+, ×, −, /}, on a g, h ∈ A ⇒ g op h ∈ A ;
— g ∈ A ⇒ (g) ∈ A ;

Svartz Page 106/187


CHAPITRE 9. PREUVES PAR INDUCTION Lycée Masséna

Autres exemples. Les expressions rationnelles ou les formules logiques (vues plus tard) peuvent être définies de la
même façon.

9.3 Preuves par induction


Le principe d’une preuve par induction (structurelle) repose sur le théorème suivant :
Théorème 9.12. Soit X ⊂ E un ensemble inductif, défini par l’ensemble de base B et les règles d’induction R. On
considère un prédicat P sur les éléments de E. Supposons que :
— P (x) est vrai pour tout x ∈ B ;
— P est héréditaire pour les règles de R : pour tout r ∈ R, si (x1 , . . . , xn ) est dans l’ensemble de définition de r,
avec n l’arité de r, et si P (xi ) est vrai pour tout i ∈ [[1, n]], alors P (r(x1 , . . . , xn )) est vrai ;
alors P est vérifiée sur l’ensemble des éléments de X.
Démonstration. Considérons l’ensemble Y des éléments de E sur lesquels P est vraie. Alors Y contient B et est stable
par les règles de R, donc par définition de l’ensemble inductif X, celui-ci est inclus dans Y .
Remarque 9.13. Le théorème précédent généralise la preuve par récurrence, car l’ensemble N peut être décrit par les
deux assertions : 0 ∈ N et x ∈ N ⇒ x + 1 ∈ N.
Exemple 9.14. L’ensemble des arbres binaires entiers peut-être défini inductivement comme suit (sur l’alphabet
{N, ∅, (, )}) :
— ∅ est un arbre binaire entier ;
— Pour g et d deux arbres binaires entiers, N (g, d) en est un également.
Alors en notant n le nombre de N dans l’écriture d’un arbre binaire et f le nombre de ∅, on a f = n + 1. En effet,
cette propriété est vérifiée pour ∅ et stable par application de la règle d’inférence.

9.4 Induction non ambiguë et définitions de fonctions sur un ensemble


inductif
9.4.1 Définition non ambiguë d’un ensemble inductif
Revenons sur l’ensemble des expressions arithmétiques sur N définie un peu plus haut dans l’exemple 9.11. On voit
sans peine que 3 − 4 + 5 où 1 + 2 × 6 sont des expressions arithmétiques valides. Dans la suite, on aimerait définir
par exemple l’évaluation d’une telle expression, dont le résultat est un élément de Q (ou de Q ∪ {Erreur !} pour gérer
les divisions par zéro). Naturellement, on voudrait définir une fonction f sur un ensemble inductif X en exploitant la
définition inductive de l’ensemble. Ainsi, il faudrait pouvoir définir f (r(x1 , . . . , xn )) à partir des f (xi ), où r est une
règle d’arité n. On voit ici apparaître un problème pour les expressions arithmétiques que l’on a défini : l’expression
3 − 4 + 5 peut être dérivée à partir de 3 − 4 et 5, mais aussi de 3 et de 4 + 5. Qu’importe la valeur que l’on veut
donner à 3 − 4 + 5 : celle-ci est incompatible avec les deux dérivations différentes proposées. La définition que l’on a
donné d’une expression arithmétique sur N est ambiguë car il est possible d’obtenir un élément de plusieurs façons. Il
est nécessaire de lever toute ambiguïté pour définir une fonction sur un ensemble inductif.
Définition 9.15. Une définition d’un ensemble inductif X est dite non ambiguë si chaque élément de X ne peut
s’obtenir que d’une seule façon à partir de B et des règles d’inférences de R.
Remarque 9.16. Cette définition est assez peu formelle, bien qu’intuitive. Il est possible de donner une définition
beaucoup plus rigoureuse, ce que je ne ferai pas ici. Voici simplement l’idée : la construction des arbres généraux de
l’exemple 9.9 est en quelque sorte le prototype d’une définition inductive quelconque, l’application d’une règle d’inférence
d’arité n dans une définition inductive pouvant être vue comme la construction d’un arbre dont la racine possède n fils.
Dans une définition inductive d’un ensemble X, on peut voir les élément de B comme des feuilles et l’application d’une
règle d’inférence r d’arité n comme un arbre 1 de racine r, celle-ci possédant n fils. On obtient ainsi une surjection de
l’ensemble des arbres dont les nœuds internes sont étiquetés par les éléments de R et les feuilles par les éléments de
B dans X. La définition inductive de X est non ambiguïté si cette surjection est injective.
Exemple 9.17. Parmi les définitions inductives vues plus haut, seule celles des expressions arithmétiques de l’exemple 9.11
est ambiguë. Voici une autre définition, non ambiguë :
1. On parle plutôt de terme dans la littérature.

Svartz Page 107/187


9.4. INDUCTION NON AMBIGUË ET DÉFINITIONS DE FONCTIONS SUR UN ENSEMBLE INDUCTIF
Lycée Masséna

— 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)).

9.4.2 Ordre sur un ensemble inductif


Un ensemble inductif défini de manière non ambiguë peut-être muni d’une relation d’ordre (non totale) directement
liée à la définition, comme on va le voir. On va d’abord définir la relation entre les xi et r(x1 , . . . , xn ) où r est une
règle d’inférence, et prolonger cette relation.
Définition 9.18. Soit R une relation sur un ensemble E. On appelle fermeture réflexive-transitive de la relation la
relation R0 définie par :

xR0 y ⇐⇒ ∃ n ≥ 0, ∃ x0 , x1 , . . . , xn ∈ E, x = x0 , y = xn et xi Rxi+1 pour tout i ∈ [[0, n − 1]]

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 :

X0 = B et ∀ n > 0 Xn = {r(x1 , . . . , xp ) | r ∈ R d’arité p, x1 , . . . , xp ∈ ∪i<n Xi , l’un des xi est dans Xn−1 }

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é.

9.4.3 Fonctions sur un ensemble inductif


Il est facile de définir une fonction sur un ensemble inductif, pourvu que l’on en possède une définition inductive
non ambiguë, comme le prouve le théorème suivant.
Théorème 9.22. Soit X un ensemble défini inductivement, avec ensemble de base B et règles de R. On suppose
l’ensemble défini de manière non ambiguë. La donnée de :
— valeurs f (x) pour tout x dans B ;
— valeurs de f (r(x1 , . . . , xn )) en fonction des xi et des f (xi ) pour toute règle r d’arité n ;

Svartz Page 108/187


CHAPITRE 9. PREUVES PAR INDUCTION Lycée Masséna

permet de définir une fonction f sur X.

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)).

9.4.4 Analyse d’une fonction récursive


Considérons un ensemble inductif X défini de manière non ambigüe. Une fonction récursive sur X utilisant la
structure à la manière du théorème 9.22 est facile à analyser :
— sa terminaison est évidente en vertu de la propriété 9.21 ;
— sa correction se montre par induction.
— sa complexité s’analyse de la même manière qu’une fonction récursive quelconque.

Svartz Page 109/187


9.4. INDUCTION NON AMBIGUË ET DÉFINITIONS DE FONCTIONS SUR UN ENSEMBLE INDUCTIF
Lycée Masséna

Svartz Page 110/187


CHAPITRE 10. LOGIQUE Lycée Masséna

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.

10.2 Syntaxe des expressions logiques et représentation arborescente


10.2.1 Définitions
Définition 10.1. Soit V un ensemble au plus dénombrable de variables logiques, qu’on notera V = {v1 , . . . , vn , ..}.
L’ensemble des expressions logiques sur V est défini inductivement :
— 0 et 1 sont des expressions logiques (aussi notées Faux et Vrai, ⊥ et >...) ;
— tout v ∈ V est une expression logique ;
— pour ψ et ϕ deux expressions logiques :
∗ (ψ ∧ φ) est une expression logique (lire « et ») ;
∗ (ψ ∨ φ) est une expression logique (lire « ou ») ;
∗ ¬ψ est une expression logique (lire « non »).
Exemple 10.2. ¬(v1 ∧ v2 ) ou encore (v1 ∨ ¬(v2 ∧ v2 )) sont des expressions logiques. ¬(v1 ∧ v2 ) et (¬v1 ∨ ¬v2 ) sont
deux expressions logiques différentes.

10.2.2 Représentation arborescente


Les expressions logiques admettent naturellement une représentation arborescente : les variables logiques et les
constantes 0 et 1 sont les étiquettes des feuilles, les nœuds internes ayant pour étiquettes les opérateurs. Un nœud
d’étiquette ∧ ou ∨ est d’arité 2, un nœud d’étiquette ¬ est d’arité 1.
Cette représentation permet de définir la longueur et la hauteur d’une expression logique :
Définition 10.3. On appelle :
— hauteur d’une expression logique la hauteur de l’arbre binaire associé ;
— longueur d’une expression logique le nombre de nœuds de l’arbre associé.
La hauteur ou la longueur peut, de manière équivalente, également être définie par induction.

10.2.3 Simplification de l’écriture


Pour raccourcir l’écriture d’une expression logique, on définit une priorité des opérateurs : ¬ est prioritaire sur ∧,
lui même prioritaire sur ∨.

Svartz Page 111/187


10.3. SÉMANTIQUE DES EXPRESSIONS LOGIQUES Lycée Masséna

∧ ∨

¬ ∨ ∧ ¬

v1 v2 ¬ v1 0 ∨

v3 ∧ ¬

v3 v1 v2

Figure 10.1 – La représentation arborescente d’une expression logique en {v1 , v2 , v3 }

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.

type 'a exp_log =


Zero | Un
| V of 'a
| Et of 'a exp_log * 'a exp_log
| Ou of 'a exp_log * 'a exp_log
| Non of 'a exp_log
;;

Voici deux fonctions 1 permettant de calculer la hauteur et la longueur d’une expression logique :

let rec longueur e=match e with


| Zero | Un | V _ -> 1
| Et (g,d) | Ou (g,d) -> 1 + longueur g + longueur d
| Non g -> 1 + longueur g
;;

let rec hauteur e=match e with


| Zero | Un | V _ -> 0
| Et (g,d) | Ou (g,d) -> 1 + max (hauteur g) (hauteur d)
| Non g -> 1 + hauteur g
;;

Avec l’exemple de la figure 10.1, on obtient bien hauteur 5 et longueur 19 :


let e = Et
(Et (Non (V 1), Ou (V 2, Non (V 3))), # val e : int exp_log =
Ou (Et (V 1, Zero), Et (Et (Non (V 1), Ou (V 2, Non (V 3))),
Non (Ou (Et (V 3, V 1), Non (V 2))))) Ou (Et (V 1, Zero), Non (Ou (Et (V 3, V 1), Non (V 2)))))
;; # 5- : unit = ()
# 19- : unit = ()
print_int (hauteur e) ;;
print_int (longueur e) ;;

10.3 Sémantique des expressions logiques


10.3.1 Distribution de vérité
Définition 10.4. Soit V un ensemble fini de variables. Une distribution de vérité sur V est une application V → {0, 1}.

Proposition 10.5. Si |V | = n, il y a 2n distributions de vérité sur V .


1. Un motif multiple (de la forme m1 | m2) avec identificateurs est possible, à condition que les mêmes identificateurs figurent des deux
côtés.

Svartz Page 112/187


CHAPITRE 10. LOGIQUE Lycée Masséna

10.3.2 Évaluation d’une expression logique. Équivalence sémantique


Définition 10.6. Soit d une distribution de vérité sur V . On définit inductivement l’évaluation Ed (ϕ) d’une expression
logique ϕ à variables dans V suivant la distribution d comme suit :
— Ed (0) = 0 ;
— Ed (1) = 1 ;
— Ed (v) = d(v) pour tout v ∈ V ;
— Ed (ϕ ∧ ψ) = Ed (ϕ)Ed (ψ) ;
— Ed (ϕ ∨ ψ) = Ed (ϕ) + Ed (ψ) − Ed (ϕ)Ed (ψ) ;
— Ed (¬ϕ) = 1 − Ed (ϕ).

Voici les tables des deux opérateurs ∧ et ∨ sur {0, 1} :

∧ 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.

10.3.3 Expressions logiques à 2 variables


2
Avec deux variables logiques v1 et v2 , il y a 16 = 22 expressions logiques, à équivalence sémantique près.
Dénombrons-les, en fonction du nombre de 1 dans la table de vérité.
• Sans 1 dans la table de vérité, il y a la formule logique 0.
• Avec un seul 1, il y en a 4 : v1 ∧ v2 , ¬v1 ∧ v2 , v1 ∧ ¬v2 et ¬v1 ∧ ¬v2 .

Svartz Page 113/187


10.4. TAUTOLOGIES, ANTILOGIES ET FORMULES SATISFIABLES Lycée Masséna

• 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 .

10.3.4 Retour au problème de l’introduction


On reprend le problème de l’introduction, et en notant égaement x la variable indiquant que x prend un dessert.
On cherche à savoir pour quelles distributions de vérité les quatre propositions s’évaluent en 1.
Reformulons ces propositions en expressions logiques en les variables A, B et C.
— si A prend un dessert, B aussi : A ⇒ B ;
— soit B, soit C prennent un dessert, mais pas les 2 : B ⊕ C ;
— A ou C prend un dessert : A ∨ C
— si C prend un dessert, A aussi : C ⇒ A.
Dressons la table de vérité de ces 4 formules logiques en l’ensemble V = {A, B, C}.

A B C A⇒B B⊕C A∨C C⇒A


0 0 0 1 0 0 1
0 0 1 1 1 1 0
0 1 0 1 1 0 1
0 1 1 1 0 1 0
1 0 0 0 0 1 1
1 0 1 0 1 1 1
1 1 0 1 1 1 1
1 1 1 1 0 1 1
La ligne grisée correspond à la seule distribution de vérité pour laquelle l’évaluation des quatre formules logiques
donne 1. On en déduit que A et B prennent un dessert, pas C.

10.4 Tautologies, antilogies et formules satisfiables


10.4.1 Définitions
Définition 10.15. Soit ϕ une expression logique sur un ensemble de variables V . On dit que :
— ϕ est une tautologie si pour toute distribution de vérité d, Ed (ϕ) = 1 ;
— ϕ est une antilogie si pour toute distribution de vérité d, Ed (ϕ) = 0 ;
— ϕ est satisfiable s’il existe une distribution de vérité d telle que Ed (ϕ) = 1.
Remarque 10.16. — On dit aussi formule valide pour une tautologie et formule fausse pour une antilogie.
— Les problèmes de détermination des expressions tautologiques, antilogiques et satisfiables sont reliés, car :
— ϕ tautologie ⇐⇒ ¬ϕ antilogie ;
— ϕ antilogie ⇐⇒ ϕ non satisfiable.

Svartz Page 114/187


CHAPITRE 10. LOGIQUE Lycée Masséna

10.4.2 Les tautologies : la base du raisonnement mathématique


Il est utile de posséder une liste de tautologies, bien qu’il soit impossible de « toutes » les lister, car elles sont en
nombre infini. Donnons-en quelques-unes, mais avant ça une petite remarque sous forme de proposition :

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.

Voici une liste de tautologies :


— négation de la négation : ¬¬v ⇔ v
— équivalence à vrai/faux : (v ⇔ 1) ⇔ v et (v ⇔ 0) ⇔ ¬v
— idempotence : v ∧ v ⇔ v et v ∨ v ⇔ v
— implication et équivalence d’une même variable : (v ⇒ v) ⇔ 1 et (v ⇔ v) ⇔ 1
— commutativité : v1 ∧ v2 ⇔ v2 ∧ v1 . De même avec ∨,⊕, ↑, ↓ et ⇔.
— équivalence des négations : (¬v1 ⇔ ¬v2 ) ⇔ (v1 ⇔ v2 ).
— lois de de Morgan : ¬(v1 ∧ v2 ) ⇔ ¬v1 ∨ ¬v2 et ¬(v1 ∨ v2 ) ⇔ ¬v1 ∧ ¬v2 .
— associativité : (v1 ∨ (v2 ∨ v3 )) ⇔ ((v1 ∨ v2 ) ∨ v3 ), de même pour ∧, ⊕ et ⇔.
— distributivité de ∨ sur ∧ et réciproquement : (v1 ∨ (v2 ∧ v3 )) ⇔ (v1 ∨ v2 ) ∧ (v1 ∨ v3 ), de même (v1 ∧ (v2 ∨ v3 )) ⇔
(v1 ∧ v2 ) ∨ (v1 ∧ v3 ).
— transitivité : ((v1 ⇒ v2 ) ∧ (v2 ⇒ v3 )) ⇒ (v1 ⇒ v3 ).
— simplification : v1 ∨ (v1 ∧ v2 ) ⇔ v1 , et v1 ∨ (¬v1 ∧ v2 ) ⇔ v1 ∨ v2 . De même v1 ∧ (v1 ∨ v2 ) ⇔ v1 , et v1 ∧ (¬v1 ∨ v2 ) ⇔
v1 ∧ v2 .
Voici les bases du raisonnement mathématique :
— tiers exclus : v ∨ ¬v ⇔ 1 et v ∧ ¬v ⇔ 0
— contraposée : (v1 ⇒ v2 ) ⇔ (¬v2 ⇒ ¬v1 ).
— double implication : (v1 ⇒ v2 ) ∧ (v2 ⇒ v1 ) ⇔ (v1 ⇔ v2 ).
— disjonction de cas : ((v1 ⇒ v2 ) ∧ (¬v1 ⇒ v2 )) ⇒ v2 .
— démonstration par l’absurde : (¬v1 ⇒ 0) ⇔ v1 .
— Modus ponens : (v1 ∧ (v1 ⇒ v2 )) ⇒ v2 .

10.5 Formes normales


Soit ϕ une expression logique. On cherche à trouver une expression sémantiquement équivalente à ϕ de la forme la
plus « simple » possible.

10.5.1 Formes normales conjonctives et disjonctives


Définition 10.19. Soit V un ensemble de variables logiques. On appelle littéral de V une expression logique de la
forme v ou ¬v, pour v ∈ V .

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).

Svartz Page 115/187


10.5. FORMES NORMALES Lycée Masséna

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.

10.5.2 Mise sous forme canonique


Avant de décrire un algorithme de mise sous forme normale conjonctive ou disjonctive, prouvons l’existence d’une
telle écriture.
Théorème 10.25. Soit ϕ une expression logique sur un ensemble de variables V = {v1 , . . . , vn }. Alors ϕ est séman-
tiquement équivalente à une disjonction de conjonctions de littéraux et à une conjonction de clauses.
Avant de montrer ce théorème, énonçons un lemme :
Lemme 10.26. La négation d’une conjonction de disjonctions est une disjonction de conjonctions, et réciproquement.
Démonstration. Il suffit d’appliquer les loi de De Morgan.
Démonstration du théorème 10.25. On montre ce théorème par induction :
— 0 est une dijonction (sans conjonction). C’est également une conjonction (d’une seule disjonction, vide).
— de même pour 1, qui est une conjonction (sans disjonction) et la disjonction d’une seule conjonction, vide.
— vi (et ¬vi ) est la disjonction d’une conjonction à un littéral, ou la conjonction d’une disjonction à un littéral.
— si ϕ admet une telle écriture, c’est le cas également pour ¬ϕ d’après le lemme.
— si ϕ = ϕ1 ∧ ϕ2 avec ϕ1 et ϕ2 vérifiant le lemme, alors ϕ est également une conjonction de disjonctions. De plus si
ϕ1 ≡ c11 ∨ · · · ∨ c1n1 et ϕ2 ≡ c21 ∨ · · · ∨ c2n2 avec les cki des conjonctions de littéraux, alors en utilisant la distributivité
de ∧ sur ∨, _
ϕ = ϕ1 ∧ ϕ2 ≡ c1i ∧ c2j
1≤i≤n1
1≤j≤n2

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 ∨ :

Svartz Page 116/187


CHAPITRE 10. LOGIQUE Lycée Masséna

— 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 :

(¬B ∧ C) ∨ (B ∧ ¬C) = ((¬B ∧ C) ∨ B) ∧ ((¬B ∧ C) ∨ ¬C)


= (¬B ∨ B) ∧ (C ∨ B) ∧ (¬B ∨ ¬C) ∧ (C ∨ ¬C)

Enfin, puisque B ∨ ¬B ≡ C ∨ ¬C ≡ 1, on obtient :

(A ⇒ B) ∧ (B ⊕ C) ∧ (A ∨ C) ∧ (C ⇒ A) ≡ (¬A ∨ B) ∧ (B ∨ C) ∧ (¬B ∨ ¬C) ∧ (A ∨ C) ∧ (¬C ∨ A)


La formule de droite est bien sous forme normale conjonctive : il y a 5 clauses à 2 littéraux.
• Reprenons l’exemple précédent pour obtenir une forme normale disjonctive, à partir de (¬A ∨ B) ∧ (¬B ∧ C ∨
B ∧ ¬C) ∧ (A ∨ C) ∧ (¬C ∨ A). On utilise maintenant la distributivité de ∧ sur ∨ :

D’une part, (A ∨ C) ∧ (¬C ∨ A) ≡ (A ∧ ¬C) ∨ (A ∧ A) ∨ (C ∧ ¬C) ∨ (A ∧ C)


≡ A ∨ (A ∧ ¬C) ∨ (A ∧ C) car A ∧ A ≡ A et C ∧ ¬C ≡ 0
≡A (simplification)

D’autre part, (¬A ∨ B) ∧ A ≡ A ∧ B (simplification)

Enfin, A ∧ B ∧ ((¬B ∧ C) ∨ (B ∧ ¬C)) ≡ (A ∧ B ∧ ¬B ∧ C) ∨ (A ∧ B ∧ B ∧ ¬C) (loi de De Morgan)


≡ A ∧ B ∧ ¬C car B ∧ B ≡ B et B ∧ ¬B ≡ 0

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

ϕ = (x1 ∧ y1 ) ∨ (x2 ∧ y2 ) ∨ · · · ∨ (xn ∧ yn )

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

Svartz Page 117/187


10.5. FORMES NORMALES Lycée Masséna

10.5.3 Formes normales conjonctives et disjonctives canoniques


Il est possible d’assurer l’unicité d’une forme normale conjonctive ou disjonctive, en rajoutant une condition sur les
conjonction et disjonction de littéraux : celles-ci doivent être de taille maximale. Commençons par les formes normales
disjonctives canoniques.

Formes normales disjonctives canoniques


Définition 10.29. Soit V = {v1 , . . . , vn } un ensemble fini de variables logiques. Un min-terme sur V est une conjonc-
tion de n littéraux dans laquelle chaque variable apparaît exactement une fois.

Exemple 10.30. Avec n = 3, ¬v1 ∧ v2 ∧ ¬v3 est un min-terme. v1 ∧ ¬v2 et v1 ∧ ¬v1 ∧ v2 n’en sont pas.

Proposition 10.31. La table de vérité d’un min-terme contient un seul 1.

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).

Démonstration. Pour l’existence, on vérifie que


_
ϕ≡ md
d | Ed (ϕ)=1


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.

Ce théorème mène à la définition suivante :


W
Définition 10.33. L’expression logique d | Ed (ϕ)=1 md est la forme normale disjonctive canonique (en abrégé FNDC)
sémantiquement équivalente à ϕ.

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.

Exemple 10.34. A ∧ B ∧ ¬C est la FNDC de la formule (A ⇒ B) ∧ (B ⊕ C) ∧ (A ∨ C) ∧ (C ⇒ A). Elle n’est composée


que d’un seul min-terme.

Exemple 10.35. Considérons ϕ = (v1 ⇒ ¬v2 ) ∧ (v1 ∨ v3 ). Dressons sa table de vérité :

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

Ainsi, ϕ ≡ (¬v1 ∧ ¬v2 ∧ v3 ) ∨ (¬v1 ∧ v2 ∧ v3 ) ∨ (v1 ∧ ¬v2 ∧ ¬v3 ) ∨ (v1 ∧ ¬v2 ∧ v3 )


n
Remarque 10.36. Cette écriture permet de montrer qu’il y a bien 22 formules logiques sur un ensemble de variables
de taille n, à équivalence sémantique près.

Svartz Page 118/187


CHAPITRE 10. LOGIQUE Lycée Masséna

Formes normales conjonctives canoniques

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.

Proposition 10.38. La négation d’un min-terme est un max-terme, et réciproquement.

Démonstration. C’est une simple application de la loi de De Morgan.

Proposition 10.39. La table de vérité d’un max-terme ne contient qu’un seul 0.

Démonstration. Immédiat par négation.

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

et chacun des ¬md est équivalent à un unique max-terme.

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.41 (Suite de l’exemple 10.34). La FNDD de la formule (A ⇒ B) ∧ (B ⊕ C) ∧ (A ∨ C) ∧ (C ⇒ A) est


composée de 7 max-termes : tous sauf ¬A ∨ ¬B ∨ C

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é :

ϕ ≡ (v1 ∨ v2 ∨ v3 ) ∧ (v1 ∨ ¬v2 ∨ v3 ) ∧ (¬v1 ∨ ¬v2 ∨ v3 ) ∧ (¬v1 ∨ ¬v2 ∨ ¬v3 )

Application aux problèmes de décision

À 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.

Svartz Page 119/187


10.6. LE PROBLÈME SAT Lycée Masséna

10.6 Le problème SAT


Le problème SAT (pour satisfiabilité) est d’avoir un algorithme permettant de décider si une expression logique
est satisfiable :
Entrée : une formule logique ϕ sur V = {v1 , . . . , vn }.
Sortie : une distribution de vérité d sur V telle que Ed (ϕ) = 1 si elle existe, la réponse « non satisfiable » sinon.

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.

Svartz Page 120/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

Chapitre 11

Graphes non pondérés

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... ?

Svartz Page 121/187


11.2. VOCABULAIRE DES GRAPHES ET PROPRIÉTÉS MATHÉMATIQUES Lycée Masséna

11.2 Vocabulaire des graphes et propriétés mathématiques


11.2.1 Graphes non orientés
Définition 11.1. Un graphe est un couple (V, E) tel que :
— V est un ensemble fini non vide, ses éléments sont appelés sommets ou nœuds.
— E est un sous-ensemble de P2 (V ), les parties à 2 éléments distincts de V . Les éléments de E sont appelés les
arêtes du graphe.
On utilise la notation anglo-saxonne : V pour vertices (sommets) et E pour edges (arêtes).

Remarque 11.2. Puisque E ⊆ V , un graphe à n sommets a au plus n2 = n(n−1)



2 arêtes.
Remarque 11.3. Les graphes au programme ne présente ni boucles (une arête reliant un sommet à lui-même),
ni multi-arêtes (plusieurs arêtes possibles entre deux sommets donnés). Le graphe associé au problème des ponts de
Königsberg présente des multi-arêtes.

Incidence et degré. On dit :


— que deux sommets v et w sont adjacents (ou voisins) s’ils sont reliés par une arête (c’est-à-dire {v, w} ∈ E) ;
— qu’une arête est incidente aux sommets qu’elle relie.
Le nombre de sommets |V | du graphe s’appelle l’ordre du graphe, que l’on notera en général n dans ce cours. Le degré
d’un sommet V , noté deg(V ) est le nombre d’arêtes qui lui sont incidentes.

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.

Svartz Page 122/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

2 1 8

3 0 6 7 9

4 5

Figure 11.2 – Un graphe non orienté à 3 composantes connexes

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

Figure 11.3 – Le cycle C6 et la clique K6

11.2.2 Arbres au sens des graphes non orientés.


Un arbre peut être vu comme un cas particulier de graphe non orienté. Le but de cette sous-section est de caractériser
les arbres parmi les graphes : ce sont les graphes connexes acycliques.
P
Proposition 11.13. Soit G = (V, E) un graphe, on a v∈V deg(v) = 2 |E|.

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|.

Proposition 11.14. Un graphe connexe d’ordre n possède au moins n − 1 arêtes.

Démonstration. La preuve se fait par récurrence sur n.


— si n = 1, c’est trivial.
— considérons n ≥ 2 et la propriété démontrée pour l’entier n − 1. Soit G = (V, E) un graphe d’ordre n, connexe.
Alors tout sommet est de degré non nul.
∗ S’il existe un sommet v de degré 1, alors le graphe induit par V \{v} est toujours connexe 2 et possède n − 1
sommets. Par hypothèse de récurrence, il possède au moins n − 2 arêtes. Donc le graphe G a au moins n − 1
arêtes.
∗ Sinon, tout sommet est de degré au moins 2, et le graphe possède |E| = 21 v∈V deg(v) ≥ n arêtes.
P

2. Pourquoi ?

Svartz Page 123/187


11.2. VOCABULAIRE DES GRAPHES ET PROPRIÉTÉS MATHÉMATIQUES Lycée Masséna

— Par principe de récurrence, la propriété est démontrée.

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.

11.2.3 Graphes orientés


Les graphes orientés sont des graphes où les arêtes ont un sens. La plupart des définitions s’adaptent à ce cadre, à
quelques modifications près.
Définition 11.21. Un graphe orienté est un couple G = (V, E) où E est un ensemble de couples d’éléments distincts
de V . Les éléments de E sont alors appelés arcs (au lieu d’arêtes).
Remarque 11.22. Un graphe d’ordre n possède donc au plus n(n − 1) arcs.
Les arcs sont alors représentés comme des flèches entre deux sommets.

Svartz Page 124/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

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

Figure 11.5 – Un graphe orienté

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

Figure 11.6 – Les composantes fortement connexes du graphe ci-dessus

Proposition 11.24. Les composantes fortement connexes d’un graphe orienté sans circuit sont réduites à des single-
tons.

Svartz Page 125/187


11.2. VOCABULAIRE DES GRAPHES ET PROPRIÉTÉS MATHÉMATIQUES Lycée Masséna

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

Figure 11.7 – Graphe sans circuit des composantes fortement connexes

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

Figure 11.8 – L’arbre vu plus haut, orienté par enracinement du sommet 2

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.

Svartz Page 126/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

Voici pour terminer quelques exemples de graphes orientés :


— réseaux routiers (il y a des routes à sens unique) ;
— (Humanité, {(h1 , h2 ) | h1 connaît h2 }) : nous sommes nombreux à connaître Alain Chabat, mais a priori il ne
nous connaît pas !
— graphe divisoriel sur [[0, n − 1]] : i → j si i|j et i 6= j.

11.3 Implémentation des graphes


Le programme officiel impose d’écrire une implémentation qui permet la suppression de sommets. On commence
donc par une implémentation très générale, avant de se restreindre à des implémentations plus classiques où les sommets
sont fixés : ce sont les entiers de [[0, n − 1]].
Une petite remarque avant de débuter : il est un peu plus difficile de manipuler des graphes non orientés que des
graphes orientés, car il faut faire attention à maintenir la non orientation. Comme il n’est pas facile de manipuler des
ensembles à deux éléments, l’information « {u, v} est une arête du graphe » est dupliquée sous la forme « u est voisin
de v, v est voisin de u ». Lorsqu’on rajoute l’arête {u, v}, il faut en pratique rajouter v aux voisins de u et u aux
voisins de v, et inversement lorsqu’on la supprime.

11.3.1 Implémentation « générale »


Dans cette implémentation, on décrit simplement un graphe par la liste de ses sommets, et de leurs voisins. Ceci
mène aux types suivants (persistants) :

type 'a sommet = {id: 'a ; voisins: 'a list} ;;


type 'a graphe_gen = 'a sommet list ;;

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.

let rec ajoute_arc g u v=match g with


| [] -> failwith "u non present"
| x::q when [Link] = u && [Link] v [Link] -> g
| x::q when [Link] = u -> {id=u ; voisins=v::[Link]}::q
| x::q -> x::(ajoute_arc q u v)
;;

let ajout_arete g u v=ajoute_arc (ajoute_arc g u v) v u ;;

Svartz Page 127/187


11.3. IMPLÉMENTATION DES GRAPHES Lycée Masséna

let rec suppr x q=match q with


| [] -> []
| y::p when y=x -> p
| y::p -> y::(suppr x p)
;;

let rec supprime_arc g u v=match g with


| [] -> failwith "u non present"
| x::q when [Link] = u -> {id=u ; voisins= (suppr v [Link])}::q
| x::q -> x::(supprime_arc q u v)
;;

let supprime_arete g u v=supprime_arc (supprime_arc g u v) v u ;;

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) ;;

let ajoute_sommet g u= if est_present g u then g else {id=u ; voisins = [] }::g ;;

let rec supprime_sommet g u=match g with


| [] -> []
| x::q when [Link]=u -> supprime_sommet q u
| x::q -> ({id=[Link] ; voisins=suppr u [Link]})::(supprime_sommet q u)
;;

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.

11.3.2 Implémentation « creuse »


Dorénavant, on suppose que l’ensemble des sommets est fixé, et l’on suppose que c’est [[0, n − 1]]. On propose une
autre implémentation où les voisins d’un sommet sont stockés dans une liste, mais on utilise un tableau pour stocker
les listes d’ajdacence, ce qui permet d’accéder à la liste d’adjacence d’un sommet donné en temps constant. Le type
est donc le suivant :
type graphe_creux = int list array ;;

2 1

3 0

4 5

3. Oui, c’est petit ! On pourra consulter [Link] pour en savoir plus.


En partant de ce principe, la meilleure approche en terme de mémoire et de calculer les gens qui sont à distance au plus 3 de vous, et ceux
à distance au plus 3 de Barack Obama, et de chercher un point commun.

Svartz Page 128/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

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.

11.3.3 Représentation « dense »


Une manière de représenter un graphe dont les sommets sont [[0, n − 1]] est d’utiliser une matrice pour indiquer
l’existence d’un arc : la matrice d’adjacence.

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.

Par exemple, la matrice du graphe précédent est :


 
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

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 :

type graphe_dense = int array array ;;

La matrice précédente est donnée par :

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.

Svartz Page 129/187


11.4. PARCOURS DE GRAPHES DONNÉS PAR LISTE D’ADJACENCE Lycée Masséna

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.

11.3.4 D’une représentation à une autre


Il faut être capable de passer d’une représentation creuse à une représentation dense. Rappelons un principe simple :
— lorsqu’on parcourt une liste, on utilise en générale un fonction récursive ;
— lorsqu’on parcourt un tableau, on utilise en général une fonction itérative.
Ce n’est bien sûr pas obligatoire, mais en général c’est plus commode. Ainsi, pour passer d’une représentation
creuse à une représentation dense, on utilisera une fonction récursive (interne) pour parcourir les listes. Inversement,
pour passer de la représentation dense à la représentation creuse, on construira les listes d’adjacence à l’aide d’une
boucle.
let creux_vers_dense g=
let n=[Link] g in
let m=Array.make_matrix n n 0 in
for i=0 to n-1 do
let rec aux q=match q with
| [] -> ()
| x::r -> m.(i).(x) <- 1 ; aux r
in aux g.(i)
done ;
m
;;

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
;;

11.4 Parcours de graphes donnés par liste d’adjacence


Les parcours de graphes sont à la base de nombreux algorithmes sur les graphes. Ils vont nous permettre de calculer
des plus courts chemins, des composantes connexes ou même fortement connexes, tester l’existence d’un cycle (circuit)
dans un graphe, etc... On suppose que le graphe est donné par listes d’adjacence, et que l’ensemble de ses sommets
est [[0, n − 1]].

Svartz Page 130/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

11.4.1 Parcours générique de graphe depuis un sommet source


On se donne un sommet source s0 , depuis lequel on veut explorer le graphe. On va donc suivre les arcs ou les arêtes
et découvrir de nouveaux sommets. On suppose donnée une structure (de données) a_traiter dans laquelle on stocke
les sommets à traiter : lorsqu’on traite un nouveau sommet, on ajoute à a_traiter la liste de ses sommets qui n’ont
pas déja été traités. Pour savoir les sommets déja traités, on utilise un tableau de booléens.

Algorithme 11.29 : Parcours générique de graphe


Entrée : Un graphe G donné par listes d’adjacences, un sommet de départ s0
a_traiter ← {s0 };
B ← [Faux, . . . , Faux] ;
B[s0 ] ← Vrai;
tant que a_traiter est non vide faire
s ← sortir un élément de a_traiter;
pour tout voisin s0 de s tel que B[s0 ] est Faux faire
a_traiter ← a_traiter ∪ {s0 };
B[s0 ] ← Vrai

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.

11.4.2 Parcours en largeur et plus courts chemins

0 1 2 3

4 5 6 7

Figure 11.9 – Un exemple de graphe à parcourir

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

Svartz Page 131/187


11.4. PARCOURS DE GRAPHES DONNÉS PAR LISTE D’ADJACENCE Lycée Masséna

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.

let plus_courts_chemins g s0=


let a_traiter=[Link] () in
let n=[Link] g in
let deja_traites=[Link] n false and pred = [Link] n s0 and dist = [Link] n (-1) in
deja_traites.(s0) <- true ;
[Link] s0 a_traiter ;
dist.(s0) <- 0 ;
while not (Queue.is_empty a_traiter) do
let s=[Link] a_traiter in
let rec aux v = match v with
| [] -> ()
| t::q when deja_traites.(t) -> aux q
| t::q -> [Link] t a_traiter ; deja_traites.(t) <- true ; dist.(t) <- dist.(s) + 1 ;
pred.(t) <- s ; aux q
in aux g.(s)
done ;
dist, pred
;;

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.

Svartz Page 132/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

0 1 2 3

4 5 6 7

Figure 11.10 – L’arbre des prédécesseurs dans le parcours en largeur depuis 0

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.

11.4.3 Parcours en profondeur


Le parcours en profondeur d’un graphe consiste à s’enfoncer le plus possible dans le graphe avant de remonter vers
des sommets déja vus, dont les voisins n’ont pas tous été découverts. On pourrait écrire un algorithme utilisant une
pile à la place d’une file pour a_traiter, mais on découvrirait tous les voisins d’un sommet d’un coup, ce qui n’est
pas un vrai parcours en profondeur (et il est important de respecter ceci dans les applications). Deux solutions :
— modifier légèrement l’algorithme générique : marquer un sommet comme traité une fois qu’il a été dépilé pour
la première fois, pas dès qu’il est découvert. Ceci implique qu’un sommet peut se trouver plusieurs fois dans la
pile (mais ne sera traité qu’une fois). L’inconvénient est une complexité spatiale O(|V | + |E|) pour la pile, au
lieu de O(|V |) ;
— utiliser une formulation récursive (solution choisie !)
De plus, dans les applications du parcours en profondeur, on ne fait en général pas seulement un parcours élémentaire
(sur un seul sommet comme dans le parcours générique) mais un parcours en profondeur « complet », qui consiste
à relancer des parcours élémentaires tant que tout le graphe n’a pas été découvert. Pour assurer une complexité
O(|V | + |E|), on mutualise le tableau des booléens, comme dans le pseudo-code ci-dessous.

Algorithme 11.33 : Parcours en profondeur complet du graphe


Entrée : Un graphe G donné par liste d’adjacence
Sortie : Une énumération des sommets correspondant au parcours en profondeur, par date de fin de
parcours décroissante
deja_vu[v] ← F aux pour tout sommet v;
Enum ← [ ];
Fonction pp(u) :
deja_vu[u] ← V rai;
pour tout voisin v de u faire
si deja_vu[v] = F aux alors
pp(v)
Rajouter u à la fin de Enum;
pour tout sommet u du graphe faire
si deja_vu[u] = F aux alors
pp(u)
Renvoyer Enum, retournée.

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.

Svartz Page 133/187


11.5. APPLICATIONS DU PARCOURS EN PROFONDEUR Lycée Masséna

0 1 2 3

4 5 6 7

Figure 11.11 – L’arbre des prédécesseurs dans le parcours en profondeur depuis 0

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|.

11.5 Applications du parcours en profondeur


11.5.1 Composantes connexes d’un graphe non orienté
Le calcul des composantes connexes d’un graphe non orienté est facile : lancer un parcours en profondeur « élémen-
taire » depuis un sommet permet de découvrir toute sa composante connexe. Ainsi, il suffit de pratiquer un parcours
en profondeur complet du graphe, en stockant les résultats des parcours élémentaires dans des listes distinctes. On
obtient donc une complexité O(|V | + |E|), voici une implémentation Caml.

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
;;

Voici le résultat, avec le graphe de la figure 11.2.

# 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]]

11.5.2 Tri topologique d’un graphe orienté sans circuit (HP)


Théorème 11.34. Soit G = (V, E) un graphe orienté, sans circuit. Alors il existe une énumération s0 , . . . , sn−1 des
sommets telle que si (si , sj ) ∈ E alors i < j.

Svartz Page 134/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

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.

Svartz Page 135/187


11.5. APPLICATIONS DU PARCOURS EN PROFONDEUR Lycée Masséna

Algorithme 11.38 : Ordre topologique d’un graphe orienté sans circuit


Entrée : Un graphe G orienté, sans circuit, donné par liste d’adjacence
Sortie : Un ordre topologique du graphe
Couleur[v] ← Blanc pour tout sommet v;
tri_top ← [ ];
Fonction pp(u) :
Couleur[u] ← Gris;
pour tout voisin v de u faire
si Couleur[v] = Blanc alors
pp(v)
si Couleur[v] = Gris alors
Erreur : il y a un cycle !
Couleur[u] ← Noir;
Rajouter u à la fin de tri_top;
pour tout sommet u du graphe faire
si Couleur[u] = Blanc alors
pp(u)
Renvoyer tri_top, en sens inverse

caleçon 0/9 chemise 10/13 montre 14/15

pantalon 1/8 chaussettes 16/17 cravate 11/12

ceinture 2/5 chaussures 6/7 veste 3/4

Figure 11.12 – Les vêtements du savant Cosinus

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 !

chaussettes montre chemise cravate caleçon pantalon chaussures ceinture veste

Figure 11.13 – Les vêtements du savant Cosinus, triés topologiquement

Svartz Page 136/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

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.

Donnons l’algorithme de Kosaraju en pseudo-code (algorithme 11.42).

Algorithme 11.42 : Algorithme de Kosaraju pour le calcul de composantes fortement connexes


Entrée : Un graphe G orienté
Sortie : Ses composantes fortement connexes
Effectuer un parcours en profondeur de G, en stockant les sommets dans l’ordre de leur date de fin de
parcours df dans une liste L;
Effectuer un parcours en profondeur de t G, mais dans la boucle principale du parcours, prendre les sommets
par date df décroissante. Chaque parcours élémentaire fournit une composante fortement connexe.

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 :

Svartz Page 137/187


11.6. RÉSOLUTION DU PROBLÈME 2-SAT Lycée Masséna

3, 4, 9, 0, 1, 5, 11, 12, 13, 7, 8, 2, 10, 6.


En relançant un parcours en profondeur complet sur le graphe transposé, l’ordre des sommets dans la boucle
principale étant celui-ci, on obtient précisément les composantes fortement connexes :

0 1 2 3 4

5 6 7 8 9

10 11 12 13

Figure 11.15 – Le graphe transposé, et les composantes fortement connexes

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.

11.6 Résolution du problème 2-SAT


On se donne une formule logique f qui est une instance du problème 2-SAT, c’est à dire une conjonction de 2-clauses
(des clauses de la forme `i ∨ `j où `i et `j sont des littéraux, égaux à une variable logique ou sa négation). On suppose
que les variables apparaîssant dans les clauses sont distinctes (sinon on pourrait simplifier facilement -et rapidement !-
la formule en propageant les littéraux unitaires). Peut-on décider rapidement si la formule f est satisfiable, et le cas
échéant, calculer une distribution de vérité qui rende la formule vraie ? La réponse est oui, et on peut le voir comme
une application du calcul des composantes fortement connexes des graphes orientés.

Svartz Page 138/187


CHAPITRE 11. GRAPHES NON PONDÉRÉS Lycée Masséna

11.6.1 Construction d’un graphe à partir d’une instance de 2-SAT


À partir d’une conjonction de 2-clauses en les variables v1 , . . . , vn , on construit un graphe à 2n sommets étiquetés
par les littéraux v1 , . . . , vn , ¬v1 , . . . , ¬vn . Chaque clause ` ∨ `0 de la formule se réécrit (¬` ⇒ `0 ) ∧ (¬`0 ⇒ `). On relie
alors les littéraux ¬` et `0 par un arc, de même que ¬`0 et `. Rechercher les variables logiques dans la formule et
construire le graphe s’effectue en temps polynomial en la taille de la formule. La formule est satisfiable si on peut
trouver une affectation des variables qui ne crée aucune implication de la forme Vrai ⇒ Faux.
Proposition 11.46. Dans le graphe associé à une expression logique instance de 2-SAT, si {`1 , . . . , `k } sont les
littéraux apparaîssant dans une composante fortement connexe, alors {¬`1 , . . . , ¬`k } est également une composante
fortement connexe.
Démonstration. Par contruction du graphe, s’il y a un arc de ` à `0 , il y a aussi un arc de ¬`0 à ¬`. Ainsi si deux
littéraux sont dans la même composante fortement connexe, les littéraux conjugués sont également dans la même
composante fortement connexe.
Remarque 11.47. En notant G le graphe ainsi construit, et G̃ le graphe où on échange vi et ¬vi pour tout i, alors
t
G̃ = G.

11.6.2 À la recherche d’une distribution de vérité


Théorème 11.48. Une formule logique f instance de 2-SAT est satisfiable si et seulement si, dans le graphe associé,
aucune composante fortement connexe ne contient à la fois une variable logique v et sa négation ¬v.
Démonstration. — La condition est nécessaire car si v et ¬v se trouvent dans la même composante fortement
connexe, cela signifie que la formule logique implique v ⇔ ¬v, qui est équivalente à 0 : f est donc antilogique.
— Pour montrer que la condition est suffisante, voici une preuve constructive : l’algorithme qui suit construit une
distribution de vérité qui satisfait la formule logique.
— calculer un ordre topologique C1 , . . . , Ck des composantes connexes ;
— remonter l’ordre topologique à l’envers, en donnant la valeur de vérité Vrai aux littéraux apparaîssant dans
Ci s’ils n’en ont pas déja.
Montrons que cet algorithme ne produit aucune implication de la forme Vrai ⇒ Faux. Puisqu’aux littéraux
apparaîssant dans une même composante est associée la même valeur de vérité, on peut supposer que dans
chacune des composante apparaît un unique littéral. Le graphe a donc 2n composantes fortement connexes, qui
sont les vi et les ¬vi . Considérons une implication ` ⇒ `0 présente dans le graphe. Alors l’implication ¬`0 ⇒ ¬`
est également présente.
— Si `0 apparaît en dernier dans l’ordre topologique, l’algorithme affecte à `0 la valeur Vraie, et donc à ¬`0 la
valeur Faux. Quelle que soit la valeur de vérité associée à `, les deux implications sont satisfaites ;
— si ¬` apparaît en dernier, le raisonnement est le même.

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

Un ordre topologique pour les composantes connexes est le suivant :

¬A, ¬B, C A, B, ¬C

Svartz Page 139/187


11.6. RÉSOLUTION DU PROBLÈME 2-SAT Lycée Masséna

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.

Svartz Page 140/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

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 Définition et représentations des graphes pondérés


12.2.1 Pondération
Définition 12.1. On considère un graphe G = (V, E) orienté ou non. Une fonction de pondération de G est une
fonction ω : E → R. Le réel ω(e) est appelé le poids de l’arête ou de l’arc e. Le graphe G = (V, E, ω) est appelé un
graphe pondéré.
On étend naturellement la fonction de pondération à tout couple de sommets pour obtenir une fonction V 2 →
R ∪ {+∞}, avec la définition suivante :

 ω((u, v)) si (u, v) ∈ E
ω 0 (u, v) = 0 si u = v
+∞ sinon.

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 ;;

Svartz Page 141/187


12.3. DÉFINITIONS ET PREMIÈRES PROPRIÉTÉS SUR LES PLUS COURTS CHEMINS Lycée Masséna

−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

Figure 12.1 – Un graphe pondéré et sa matrice d’adjacence

Voici un exemple de graphe pondéré, et sa matrice d’adjacence (figure 12.1).

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.

12.3 Définitions et premières propriétés sur les plus courts chemins


12.3.1 Définition
s’étend au poids d’un chemin : pour p = v0 , v1 , . . . , vn un chemin dans
Définition 12.3. La notion de poids d’un arc P
n−1
un graphe, on définit son poids comme ω(p) = i=0 ω(vi , vi+1 ) ∈ R.

Venons-en à la définition de la distance entre deux sommets.

Définition 12.4. Pour s et t deux sommets dans le graphe, on appelle distance de s à t, notée δ(s, t), comme

δ(s, t) = inf{ω(p) | p est un chemin de s à t} ∈ R ∪ {±∞}

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éfinition 12.6. Pour s et t deux sommets tels que δ(s, t) ∈


/ {±∞}, on appelle plus court chemin de s à t un chemin
de poids δ(s, t) entre s et t.

Svartz Page 142/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

12.3.2 Différents problèmes de plus courts chemins


Lors des calculs de plus courts chemins, nos algorithmes donneront une réponse erronée pour les sommets situés
sur des circuits (accessibles) de poids strictement négatifs. On supposera donc qu’il n’y en a pas, même si en pratique
il est facile de les détecter.
Dans le problème du calcul de plus courts chemins, on peut énumérer 4 cas :
1. calcul d’un plus court chemin depuis une source s et une destination t ;
2. calcul de plus courts chemins depuis une origine unique s fixée ;
3. calcul de plus courts chemins vers une destination unique s fixée ;
4. calcul de plus courts chemins entre deux couples quelconques de sommets s et t.
Le problème (3) est symétrique du problème (2) : en effet, on se ramène au problème (2) à partir du problème (3)
en travaillant sur le graphe transposé. On verra par la suite des algorithmes pour résoudre les problèmes (2) et (4).
Pour le problème (1), on se contentera du problème (2) : même s’il est a priori plus facile, on ne connaît pas à l’heure
actuelle d’algorithme de résolution de (1) plus efficace asymptotiquement qu’un algorithme qui résout (2).

12.3.3 Optimalité des solutions aux sous-problèmes


Le problème du calcul d’un plus court chemin entre deux sommets est un problème d’optimisation. Mais calculer
un plus court chemin entre deux sommets fournit une solution à des sous-problèmes :
c
Proposition 12.7 (Optimalité des sous-chemins d’un plus court chemin). Soit s t un plus court chemin passant
c c
par un sommet u. Alors les deux morceaux s 1 u et u 2 t sont des plus courts chemins de s à u et de u à t.
Démonstration. La démonstration est facile, et classique : s’il existait un chemin plus court de s à u, noté c01 , on
c01 c2
obtiendrait un chemin de s à t en considérant s u t, de poids ω(c01 ) + ω(c2 ) < ω(c1 ) + ω(c2 ) = ω(c), ce qui est
absurde. De même pour le chemin c2 .
La propriété précédente montre que dans le problème du calcul de plus courts chemins réside une optimalité des
solutions aux sous-problèmes : déterminer un plus court chemin de s à t fournit en même temps des plus courts chemins
depuis s (ou vers t) pour tous les sommets situés sur le chemin. C’est une caractéristique des problèmes pour lesquels
les méthodes de programmation dynamique peuvent s’appliquer. Parmi les algorithmes que l’on va voir, trois d’entre
eux font usage de programmation dynamique, le dernier étant un algorithme glouton (un algorithme pour lequel il y
a un choix localement optimal qui est globalement optimal, et évite le recours à la programmation dynamique, plus
lourde).
Comme souvent en programmation dynamique, on commence par calculer les valeurs optimales (ici les δ(s, t))
avant de modifier l’algorithme pour calculer les solutions optimales (pour tout (s, t), un chemin c de s à t pour lequel
ω(c) = δ(s, t)). On va donc se concentrer sur le calcul des δ(s, t).

12.4 Plus courts chemins à origine unique


Le but de cette section est de calculer les distances entre une origine s fixée et tous les sommets du graphe, donc
(δ(s, t))t∈G . On va voir deux algorithmes : l’algorithme de Bellman-Ford (HP) et l’algorithme de Dijkstra. Le premier
a une moins bonne complexité, mais a le mérite d’être très simple et de s’appliquer lorsqu’il y a des arcs de poids
strictement négatifs, alors que l’algorithme de Dijkstra nécessite que tous les arcs soient de poids positifs. Les deux
fonctionnent sur le même principe : il s’agit de relâcher les arcs du graphe, dans un certain ordre.
Dans la suite, on suppose que le graphe ne possède pas de circuit de poids strictement négatif.

12.4.1 Relâchement d’arcs


Lemme 12.8 (Inégalité triangulaire). Soit s, t et u trois sommets d’un graphe pondéré G. Alors δ(s, t) ≤ δ(s, u) +
ω(u, t).
Démonstration. S’il existe un chemin de s à u et un arc de u à t, on a δ(s, u) < +∞ et ω(u, t) < +∞. On obtient
un chemin de s à t de poids δ(s, u) + ω(u, t) en prenant un plus court chemin de s à u et l’arc (u, t), donc δ(s, t) ≤
δ(s, u) + ω(u, t). Sinon, on a δ(s, u) + ω(u, t) = +∞, et l’inégalité reste valable.
Lemme 12.9. Soit t un sommet accessible depuis s. Alors il existe un chemin de poids δ(s, t) entre s et t, composé
de sommets tous distincts.

Svartz Page 143/187


12.4. PLUS COURTS CHEMINS À ORIGINE UNIQUE Lycée Masséna

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) !

12.4.2 Algorithme de Bellman-Ford (HP)


L’algorithme de Bellman-Ford est directement inspiré de la proposition 12.12 précédente, et du lemme 12.9 : avec
n = |V |, il suffit de relâcher n − 1 fois tous les arcs du graphe G = (V, E, ω) pour calculer les δ(s, t). Voici l’algorithme
en pseudo code :

Algorithme 12.14 : Algorithme de Bellman-Ford


Entrée : Un graphe pondéré G = (V, E, ω) donné par listes d’adjacences, un sommet s
Sortie : Les distances δ(s, t) pour tout t ∈ V
d[t] ← +∞ pour tout t ∈ V ; d[s] ← 0;
n ← |V |;
pour i entre 1 et n − 1 faire faire
pour j entre 0 et n − 1 faire faire
pour tout voisin u de j faire faire
d[u] ← min(d[u], d[j] + ω(j, u))

Renvoyer d

Svartz Page 144/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

Correction de l’algorithme de Bellman-Ford. Montrons que l’algorithme de Bellman-Ford est correct si le


graphe n’a pas de circuit de poids strictement négatif. Soit t un sommet quelconque du graphe, accessible depuis s.
Comme on ne fait que relâcher des arcs dans l’algorithme, le lemme 12.11 implique d[t] ≥ δ(s, t) à la fin de l’algorithme.
Le lemme 12.9 assure l’existence d’un plus court chemin s = s0 , s1 , . . . , sk = t où les sommets sont distincts : on a donc
k ≤ n − 1. Ainsi, l’arc (s0 , s1 ) est relâché lors du tour i = 1 de la boucle principale, l’arc (s1 , s2 ) est relâché pendant
le tour i = 2, et de même jusqu’à l’arc (sk , sk−1 ) relâché lors du tour i = k. Puisque les arcs sont relâchés dans l’ordre
du chemin, on a bien d[t] ≤ δ(s, t) à la fin de l’algorithme. La propriété « d[t] = +∞ si t n’est pas accessible depuis s »
est clairement un invariant des boucles de l’algorithme, donc celui-ci calcule correctement toutes les distances δ(s, t).

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

Ce qui est absurde.


Ainsi, il suffit de faire n tours de la boucle principale au lieu de n − 1 pour pouvoir tester l’existence d’un circuit
de poids total strictement négatif accessible depuis s.

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.

12.4.3 Algorithme de Dijkstra


L’algorithme précédent a une complexité très élevée comparée au parcours en largeur du chapitre précédent, qui
s’effectuait en temps linéaire O(a + n). L’algorithme de Dijkstra 1 que l’on va voir est une généralisation du parcours en
largeur : il consiste également à traiter les sommets un par un, par distance à l’origine s croissante. Pour fonctionner,
l’algorithme a besoin d’une hypothèse non nécessaire pour l’algorithme de Bellman-Ford :

On suppose pour l’algorithme de Dijkstra que les arcs ont un poids positif.

1. prononcer « Daïjkstra »

Svartz Page 145/187


12.4. PLUS COURTS CHEMINS À ORIGINE UNIQUE Lycée Masséna

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

Avant le début Tour i = 1 Tour i = 2


t 5 x t 5 x
2 4 2 4
6 -2 6 -2
8 -3 8 -3
s 0 7 s 0 7
-4 -4
7 7
7 2 7 -2
2 9 2 9
y z y z

Tour i = 3 Tour i = 4
Figure 12.3 – Déroulement de l’algorithme de Bellman-Ford

Algorithme 12.16 : Algorithme de Dijkstra


Entrée : Un graphe pondéré G = (V, E, ω) donné par listes d’adjacences, avec ω(E) ⊂ R+ , un sommet s
Sortie : Les distances δ(s, t) pour tout t ∈ V
d[t] ← +∞ pour tout t ∈ V ; d[s] ← 0;
H ← ∅ ; F ← {s};
tant que F 6= ∅ faire
u ← Retirer de F un sommet v vérifiant d[v] minimal parmi les sommets de F ;
pour tout voisin v de u faire
si v n’est ni dans F ni dans H alors
Ajouter v à F
d[v] ← min(d[v], d[u] + ω(u, v))
Ajouter u à H
Renvoyer d

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.

Correction de l’algorithme. Faisons déja plusieurs observations :


— l’algorithme, pour travailler sur le tableau d, se contente de relâcher des arcs : on aura donc d[t] ≥ δ(s, t) pour
tout sommet t à la fin de l’algorithme.
— l’algorithme faisant notamment un parcours générique, à la fin de l’algorithme, tous les sommets t accessibles
depuis s sont dans l’ensemble H, et vérifient tous d[t] < +∞.
Montrons maintenant que l’algorithme est correct, via la proposition suivante :

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 ;

Svartz Page 146/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

— 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.

12.5 Plus courts chemins pour tous couples de sommets


On souhaite maintenant calculer δ(s, t)) pour tout (s, t) ∈ V 2 . Avec un graphe représenté par listes d’adjacence,
il est possible d’appliquer n fois les algorithmes de Bellman-Ford et de Dijkstra. Ce dernier reste très efficace si le
graphe est « creux » (complexité O((a + n)n log n)) mais suppose les poids positifs. On va donner ici deux algorithmes
fonctionnant sur des graphes donnés par matrices d’adjacence. Le premier est assez simple et possède une complexité
O(n3 log n). Le second (Floyd-Warshall) est plus astucieux et plus efficace, car sa complexité se réduit à O(n3 ).

Svartz Page 147/187


12.5. PLUS COURTS CHEMINS POUR TOUS COUPLES DE SOMMETS Lycée Masséna

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

H = ∅, F = {s} H = {s}, F = {t, y} H = {s, y}, F = {t, x, z}


t x t x t x
1 1 1
8 13 8 9 8 9
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 7 5 7 5 7
7 2 7 2 7 2
y z y z y z

H = {s, y, z}, F = {t, x} H = {s, y, z, t}, F = {x} H = {s, y, z, t, x}, F = ∅

Figure 12.4 – Déroulement de l’algorithme de Dijkstra

12.5.1 Multiplication de matrices (HP)


Soit M la matrice d’adjacence, et considérons la suite de matrice (Di )i≥1 définie par

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

Proposition 12.20. Avec cette construction, dm


i,j est la longueur d’un plus court chemin de longueur au plus m dans
le graphe, entre les sommets i et j.

Démonstration. Immédiat par récurrence.

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 :

Svartz Page 148/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

Algorithme 12.22 : Algorithme de multiplication matricielle


Entrée : Un graphe pondéré G = (V, E, ω) donné par sa matrice d’adjacence M
Sortie : La matrice (δ(i, j))0≤i,j<n des plus courtes distances entre deux sommets quelconques du graphe
A ← M;
k ← 1;
tant que k < n − 1 faire
A ← A ? A;
k ←2×k
Renvoyer A

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 !

12.5.2 Algorithme de Floyd-Warshall


L’algorithme de Floyd-Warshall est un autre algorithme de programmation dynamique qui calcule une suite de
matrices (Mi ), mais le passage de Mi à Mi+1 se fait simplement avec une complexité O(n2 ).

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).

Svartz Page 149/187


12.5. PLUS COURTS CHEMINS POUR TOUS COUPLES DE SOMMETS Lycée Masséna

−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 ).

En effet, remplacer mki,k par mk+1


i,k ne change rien, car il n’y a pas de circuit de poids strictement négatif bouclant
sur k. Voici donc l’algorithme de Floyd-Warshall :

Complexité. Elle est clairement de O(n3 ), en temps comme en mémoire.

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.

Svartz Page 150/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

Algorithme 12.27 : Algorithme de Floyd-Warshall


Entrée : Un graphe pondéré G = (V, E, ω) donné par sa matrice d’adjacence M
Sortie : La matrice (δ(i, j))0≤i,j<n des plus courtes distances entre deux sommets quelconques du graphe
A ← copie(M );
pour tout k entre 0 et n − 1 faire faire
pour tout i entre 0 et n − 1 faire faire
pour tout j entre 0 et n − 1 faire faire
ai,j ← min(ai,j , ai,k + ak,j )

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.

Algorithme 12.29 : Algorithme de Warshall : calcul de la fermeture transitive d’un graphe


Entrée : Un graphe G = (V, E) donné par sa matrice d’adjacence M
Sortie : La matrice (bi,j )0≤i,j<n d’accessibilité dans G : bi,j est vrai si et seulement si il existe un chemin
entre i et j dans G.
bi,j ← Vrai si i = j ou mi,j = 1, Faux sinon.;
pour tout k entre 0 et n − 1 faire faire
pour tout i entre 0 et n − 1 faire faire
pour tout j entre 0 et n − 1 faire faire
bi,j ← bi,j ∨ (bi,k ∧ bk,j )

Renvoyer A

12.5.3 Résumé des algorithmes de plus courts chemins


On a vu dans ce chapitre 4 algorithmes, voici un résumé des complexités.

Bellman-Ford Dijkstra Multiplication matricielle Floyd-Warshall


Limitation − poids positifs − −
O((a + n) log n) (tas min)
problème (2) O(n(a + n)) − −
O(n2 ) (tableau)
O((a + n)n log n) (tas min)
problème (4) O(n2 (a + n)) O(n3 log n) O(n3 )
O(n3 ) (tableau)

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) !

Svartz Page 151/187


12.6. ARBRE COUVRANT DE POIDS MINIMAL (HP) Lycée Masséna

−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

12.6 Arbre couvrant de poids minimal (HP)


Définition 12.31. Soit G = (V, E, ω) un graphe pondéré, non orienté, connexe, dont la fonction de pondération est
à valeurs dans R+ . On appelle graphe
Pcouvrant de G un sous-graphe connexe G0 = (V, E 0 ) tel que E 0 ⊆ E. On appelle
0
poids du graphe couvrant la somme e∈E 0 ω(e). Si G est un arbre, on dit que c’est un arbre couvrant.
Le problème de « l’arbre couvrant minimal » est de trouver un graphe couvrant de poids minimal. La propriété
suivante indique que l’on peut chercher un arbre.

Svartz Page 152/187


CHAPITRE 12. GRAPHES PONDÉRÉS Lycée Masséna

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.

Algorithme 12.33 : Algorithme de Prim


Entrée : Un graphe pondéré, non orienté, connexe G = (V, E, ω) donné par listes d’adjacence, avec
ω(E) ⊂ R+ , un sommet s
Sortie : Un ensemble d’arêtes formant un arbre couvrant minimal
n ← |V | ; d[t] ← +∞ pour tout t ∈ V ; d[s] ← 0;
p[t] ← t pour tout t ∈ V ;
H ← ∅ ; F ← {s};
E 0 ← ∅;
pour i de 0 à n − 1 faire
u ← Retirer de F un sommet v vérifiant d[v] minimal parmi les sommets de F ;
si u 6= s alors
E 0 ← E 0 ∪ {{u, p[u]}}
pour tout voisin v de u faire
si v n’est ni dans F ni dans H alors
Ajouter v à F
si d[v] > ω(u, v) alors
d[v] ← ω(u, v);
p[v] ← u
Ajouter u à H
Renvoyer E 0

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).

Svartz Page 153/187


12.6. ARBRE COUVRANT DE POIDS MINIMAL (HP) Lycée Masséna

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

H = ∅, F = {s} H = {s}, F = {y, t} H = {s, y}, F = {t, z, x}


t x t x t x
1 1 1
3 4 1
9 9 9
s 3 4 s 3 4 s 3 4
9 9 9
5 5 5
7 2 7 2 7 2
y z y z y z

H = {s, y, z}, F = {t, x} H = {s, y, z, t}, F = {x} H = {s, y, z, t, x}, F = {}

Figure 12.7 – Déroulement de l’algorithme de Prim : le poids minimal d’un arbre couvrant est 11

Svartz Page 154/187


CHAPITRE 13. LANGAGES ET EXPRESSIONS RATIONNELLES Lycée Masséna

Chapitre 13

Langages et expressions rationnelles

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

Svartz Page 155/187


13.2. MOTS SUR UN ALPHABET Lycée Masséna

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.

13.2 Mots sur un alphabet


13.2.1 Définition et structure mathématique
Définition 13.1. Un alphabet est un ensemble fini non vide de symboles (les lettres). Dans tout le chapitre, on notera
Σ un alphabet.
Exemple 13.2. — Σ = {A, C, T, G} langage utilisé pour décrire des séquences d’ADN ;
— Σ = {0, 1, 2, . . . , 9} chiffres romains ;
— Σ = {a, b, . . . , z} lettres minuscules ;
— caractères ASCII ;
— etc...
Définition 13.3. On appelle mot sur un alphabet Σ une suite finie de symboles de Σ. On notera m = m1 · · · mk une
telle suite et |m| la longueur d’un mot. Le mot vide (de longueur zéro) sera noté ε. On note aussi |m|a pour le nombre
d’occurences de la lettre a ∈ Σ dans le mot m.
Définition 13.4. On note Σ∗ l’ensemble des mots sur Σ, et Σ+ = Σ∗ \{ε} l’ensemble des mots non vides.
Définition 13.5. Pour u = u1 u2 · · · un et v = v1 v2 · · · vk deux mots sur un alphabet Σ, on appelle concaténation de u
et v le mot u1 u2 · · · un v1 v2 · · · vk . Ce mot sera noté u · v voire simplement uv.
Proposition 13.6. (Σ∗ , ·) est un monoïde (ensemble muni d’une l.c.i associative et d’un neutre), de neutre ε.
Démonstration. immédiat.
Proposition 13.7. Pour deux mots u et v sur Σ, on a |uv| = |u| + |v| et |uv|a = |u|a + |v|a pour toute lettre a de Σ.
(ainsi |.| et |.|a sont des morphismes de monoïde.
Remarque 13.8. À part ε, il n’y a pas d’élément inversible dans ce monoïde. Toutefois, il est simplifiable à gauche
et à droite : uv = uw ⇒ v = w et vu = wu ⇒ v = w.
Définition 13.9. Pour m un mot de Σ∗ , on appelle :
— préfixe de m un mot u tel qu’il existe un mot v vérifiant m = uv ;
— suffixe de m un mot u tel qu’il existe un mot v vérifiant m = vu ;
— facteur de m un mot u tel qu’il existe deux mots v et w vérifiant m = vuw ;
— sous-mot de m = m1 · · · mk un mot u = u1 · · · up tel qu’il existe une application ϕ : [[1, p]] → [[1, k]] strictement
croissante vérifiant u1 · · · up = mϕ(1) · · · mϕ(p) .

13.2.2 Lemme de Lévi et application à la commutativité


Lemme 13.10. Soit m un mot, et x et y deux préfixes de m. Alors x est préfixe de y ou y est préfixe de x.
Démonstration. Si x est de longueur inférieure à celle de y, x est préfixe de y car ses lettres sont les premières de m
qui sont aussi les premières de y. Sinon c’est l’inverse.
Lemme 13.11 (Lévi). Soient u, v, w et z quatre mots de Σ∗ tels que uv = wz. Alors il existe un unique mot t ∈ Σ∗
tel que l’une des conditions suivantes est vérifiée :
— u = wt et z = tv ;
— w = ut et v = tz.
Démonstration. u et w sont préfixes de uv = wz, donc l’un est préfixe de l’autre, disons w préfixe de u. Il existe alors
un unique mot t ∈ Σ∗ tel que u = wt. On a ensuite uv = wtv = wz, donc z = tv. Évidemment, les deux conditions
s’excluent, sauf si u = w et v = z, auquel cas t = ε.

Svartz Page 156/187


CHAPITRE 13. LANGAGES ET EXPRESSIONS RATIONNELLES Lycée Masséna

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.

13.2.3 Mots de Dyck


Définition 13.13. Sur Σ = {a, b}, on considère la valuation définie par ν(a) = 1 et ν(b) = −1. Elle s’étend aux mots
Pk
de Σ∗ : pour m = m1 · · · mk , on pose ν(m) = i=1 ν(mi ). Un mot m sur Σ∗ est dit de Dick s’il satisfait les deux
conditions suivantes :
— ν(m) = 0
— tout préfixe p de m vérifie ν(p) ≥ 0.
Exemple 13.14. aabbab est un mot de Dyck, alors que aab ou abba n’en sont pas.
Remarque 13.15. Ces mots sont également appelés mots bien parenthésés : si, dans une expression arithmétique on
ne garde que les parenthèses que l’on remplace par a (pour une ouvrante) et b (pour une fermante) alors on obtient
un mot de Dyck. Par exemple ((3 − 4) × 5 + 2 × (5 − (7 + 3)))/2 → aabaabbb.
Dans la suite, on note D l’ensemble des mots de Dyck. Voyons comment construire des mots de Dyck par conca-
ténation.
Proposition 13.16. Le mot vide ε est dans D, et pour deux mots u et v quelconques de D, le mot aubv est également
dans D.
Démonstration. C’est clair pour ε. Soient u et v deux mots de D.
— D’une part, ν(aubv) = 1 + ν(u) − 1 + ν(v) = 0 car ν(u) = ν(v) = 0.
— D’autre part, si p est un préfixe de aubv :
— soit p = ε et ν(p) = 0 ;
— soit p est un préfixe de au de la forme ap0 avec p0 préfixe de u, et donc ν(p) = 1 + ν(p0 ) > 0 ;
— soit p est un préfixe de la forme aubp0 avec p0 préfixe de v, et donc ν(p) = ν(p0 ) ≥ 0.
Ce qui prouve que aubv est bien dans D.

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é.

Svartz Page 157/187


13.3. LANGAGES Lycée Masséna

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 :

ϕ : Mn,n \Dn −→ Mn+1,n−1


m = pv 7−→ p̄v
où p̄ est le mot obtenu à partir de p en échangeant les a et les b. Comme p est de valuation −1, il s’ensuit que v est
de valuation 1, de même que p̄. Ainsi ϕ(m) = p̄v est de valuation 2 et est bien dans Mn+1,n−1 . Inversement, pour un
mot m de Mn+1,n−1 , l’ensemble de ses préfixes de valuation 1 est non vide, car ν(ε) = 0 et ν(m) = 2, et la valuation
évolue par pas de ±1 lorsqu’on parcourt le mot. Notons q le plus petit préfixe de m de valuation 1, et considérons
l’application :

ψ : Mn+1,n−1 −→ Mn,n \Dn


m = qv 7−→ q̄v
Pour les mêmes raisons que précédemment, ψ est bien à valeur dans Mn,n \Dn . De plus, ψ et ϕ sont  réciproques
l’une de l’autre. Il s’ensuit que Mn+1,n−1 et Mn,n \Dn ont même cardinal. Or Mi,j a pour cardinal i+j
i . Ainsi,
        
2n 2n (2n)! (2n)! 2n n 1 2n
Cn = |Dn | = |Mn,n | − |Mn+1,n−1 | = − = 2
− = 1− =
n n+1 n! (n + 1)!(n − 1)! n n+1 n+1 n

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é Σ∗ .

Définition 13.21. Un langage sur un alphabet Σ est une partie de Σ∗

Exemple 13.22. Les ensembles suivants sont des langages :


— langage des mots de Dyck sur {a, b} ;
— langage des mots de la forme an bn sur {a, b} ;
— langage des mots contenant plus de a que de b sur {a, b} ;
1. Eugène Charles Catalan, 1814-1894, mathématicien belge.

Svartz Page 158/187


CHAPITRE 13. LANGAGES ET EXPRESSIONS RATIONNELLES Lycée Masséna

— 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 ;
— ...

Proposition 13.23. L’ensemble de tous les langages est indénombrable.

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.

13.3.2 Opérations sur les langages


Fixons un alphabet Σ. Sur l’ensemble des langages d’alphabet Σ, on peut considérer les opérations suivantes :
— opérations ensemblistes (union, intersection, différence, différence symétrique, etc...) ;
— concaténation : L1 · L2 = {m1 · m2 | m1 ∈ L1 et m2 ∈ L2 } ;
— puissance : on note L = {ε} et pour n ≥ 1, Ln = L · Ln−1 (remarque : ne pas confondre L2 avec {u · u | u ∈
0

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 Σ .

13.3.3 Expressions rationnelles et langages rationnels


Définition 13.25. L’ensemble des expressions rationnelles sur un alphabet Σ est défini inductivement :
— ∅ et ε sont des expressions rationnelles ;
— pour tout a ∈ Σ, a est une expression rationnelle ;
— pour e1 , e2 deux expressions rationnelles, les expressions (e1 + e2 ), (e1 e2 ) et e∗1 sont rationnelles.

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

Figure 13.1 – L’arbre associé à l’expression rationnelle ((((ab) + c)a)∗ + (cb∗ )) + ε.

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.

Svartz Page 159/187


13.3. LANGAGES Lycée Masséna

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 .

Proposition 13.34. L’ensemble Rat(Σ) des langages rationnels est dénombrable.

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.

Corollaire 13.35. Il existe des langages non rationnels.

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.

13.3.4 Quelques réductions


Si un langage dénoté par une expression rationnelle n’est ni vide ni réduit à {ε}, on va montrer qu’il existe une
expression rationnelle dénotant ce langage (à ε près) sans ∅ et sans ε. Ceci permet d’alléger l’implémentation.

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).

Démonstration. La démonstration se fait par induction :


— si e = a ∈ Σ ou e = ε, il n’y a rien à montrer.
— si e s’écrit e1 + e2 avec e1 , e2 deux expressions rationnelles, avec L(e) 6= ∅, alors l’un au moins des deux langages
L(e1 ) et L(e2 ) est non vide :
— si L(e1 ) = ∅, alors L(e2 ) 6= ∅, donc par hypothèse d’induction il existe e02 dénotant L(e2 ) avec e02 sans ∅.
Comme L(e) = L(e02 ), c’est terminé ;
— de même si L(e2 ) = ∅ ;
— sinon, il existe e01 et e02 sans ∅ telles que L(ei ) = L(e0i ) pour i ∈ {1, 2}. Alors e0 = e1 + e2 est sans ∅ et dénote
L(e).
3. La démonstration suivie est classique : pour montrer qu’un ensemble est non vide, il suffit de montrer qu’il est gros !
4. Un autre argument est que les expressions rationnelles sur Σ forment un langage sur Σ ∪ {∅, ε, +, ∗, ·, (, )}.

Svartz Page 160/187


CHAPITRE 13. LANGAGES ET EXPRESSIONS RATIONNELLES Lycée Masséna

— 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

e01 + ε e01 + ε e01 + e02 + ε e01 + e02 + ε e01 + ε 0


e1 + ε e1 e2 + e02
0 0
e1 e2 + e01 + e02 + ε
0 0

e∗1
e1 ε e01 e01 + ε
e∗1 ε(exclus) e0∗
1 e0∗
1

13.4 Langages locaux et expressions rationnelles linéaires


13.4.1 Expressions rationnelles linéaires
Définition 13.39. Une expression rationnelle e sur un alphabet Σ est dite linéaire si chaque lettre de Σ apparaît au
plus une fois dans e.

Exemple 13.40. L’expression ε + a(b∗ + c) est linéaire. aa∗ ne l’est pas.

13.4.2 Langages locaux


Proposition 13.41. Soit L un langage sur l’alphabet Σ. On définit :
— P (L) = {a ∈ Σ | aΣ∗ ∩ L 6= ∅} l’ensemble des premières lettres des éléments de L ;
— S(L) = {a ∈ Σ | Σ∗ a ∩ L 6= ∅} l’ensemble des dernières lettres des éléments de L ;
— F (L) = {f ∈ Σ2 | Σ∗ f Σ∗ ∩ L 6= ∅} l’ensemble des facteurs de taille 2 des éléments de L ;
— N (L) = Σ2 \F (L) l’ensemble des mots de taille 2 qui ne sont facteurs d’aucun mot de L.
Alors L\{ε} ⊆ (P (L)Σ∗ ∩ Σ∗ S(L))\Σ∗ N (L)Σ∗ .

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.

Proposition 13.45. Soient P ⊂ Σ, S ⊂ Σ, F ⊂ Σ2 , et N = Σ2 \F Alors L = (P Σ∗ ∩ Σ∗ S)\Σ∗ N Σ∗ est local.

Svartz Page 161/187


13.4. LANGAGES LOCAUX ET EXPRESSIONS RATIONNELLES LINÉAIRES Lycée Masséna

Démonstration. Clairement, P (L) ⊂ P, S(L) ⊂ S, et F (L) ⊂ F , donc comme ε ∈


/ L,

L ⊂ (P (L)Σ∗ ∩ Σ∗ S(L))\Σ∗ N (L)Σ∗ ⊂ L

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.

13.4.3 Propriétés de clôture des langages locaux


L’ensemble des langages locaux est stable par certaines opérations. Voyons comment construire des langages locaux à
partir d’autres langages locaux. Dans la suite, on fera des opérations entre deux langages L1 et L2 , supposés locaux.
On note pour i ∈ {1, 2} Pi , Si , Fi et Ni les ensembles P (Li ), S(Li ), F (Li ) et N (Li ).

a) Intersection

Proposition 13.47. L’intersection de deux langages locaux est local.

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.

b) Union et Concaténation sur alphabets disjoints


Les langages locaux ne sont pas stables par union et concaténation, par contre si L1 et L2 sont locaux sur alphabets
disjoints, c’est le cas.

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 .

Démonstration. Posons P = P1 ∪ P2 , S = S1 ∪ S2 et F = F1 ∪ F2 , et N = Σ2 \F . Ce sont clairement les ensembles


caractéristiques de L, donc L\{ε} ⊂ P Σ∗ ∩ Σ∗ S\Σ∗ N Σ∗ . Soit m 6= ε un mot de P Σ∗ ∩ Σ∗ S\Σ∗ N Σ∗ . Si la première
lettre de m est dans P1 , on montre de proche en proche que tous les facteurs de taille 2 sont dans F1 , et sa dernière
lettre dans S1 . Donc m ∈ L1 ⊂ L1 ∪ L2 . De même si la première lettre est dans P2 . Ainsi L1 ∪ L2 est local.

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

Soit m un mot de P (L)Σ∗ ∪ Σ∗ S(L)\Σ∗ N (L)Σ∗ .


— si sa première lettre est dans P1 , considérons m1 son plus grand préfixe dans Σ∗1 . Ses facteurs de taille 2 sont
tous dans Σ21 , donc dans F1 .
— si m1 = m (auquel cas ε ∈ L2 ), alors la dernière lettre de m est dans S1 , donc m ∈ L1 ⊆ L1 L2 .

Svartz Page 162/187


CHAPITRE 13. LANGAGES ET EXPRESSIONS RATIONNELLES Lycée Masséna

— sinon, notons m2 tel que m1 m2 = m. Le facteur de taille 2 constitué de la dernière lettre de m1 et la


première de m2 est nécessairement dans S1 P2 , donc m1 ∈ L1 . On montre de proche en proche que les
facteurs suivants sont tous dans Σ22 , donc dans F2 . La dernière lettre de m est dans Σ2 , donc dans S2 . Ainsi
m2 ∈ L2 , et m ∈ L.
— sinon, on a ε ∈ L1 et on montre facilement que m ∈ L2 ⊆ L1 L2 .
Ainsi, L est local.
Remarque 13.51. Là encore, si Σ1 ∩ Σ2 6= ∅, la propriété est fausse : par exemple {ab} et {bc} sont tous deux locaux
mais pas leur concaténation {abbc}, qui devrait contenir abc.

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∗ .

d) Corollaire On déduit des points précédents le théorème qui suit :


Théorème 13.53. Le langage associé à une expression linéaire est un langage local.
Démonstration. ε et ∅ sont des langages locaux (les ensembles caractéristiques sont vides). Le langage La = {a} sur
Σ est local, avec P (La ) = S(La ) = {a} et F (La ) = ∅. Ensuite, une expression linéaire est construite comme e1 + e2 ou
e1 e2 avec e1 et e2 deux expressions linéaires sur alphabets disjoints, ou encore comme e∗ avec e linéaire. On conclut
par induction.
Remarque 13.54. La réciproque est fausse : le langage dénoté par aa∗ est local, car égal à Σ+ = Σ∗ \{ε}. En revanche,
il n’est pas local. De même pour L(baa∗ c), par exemple.
Remarque 13.55. Pour L = L1 L2 , L = L1 ∪ L2 ou L = L∗1 , les expressions donnant P (L), S(L) et F (L), en fonction
des P (Li ), S(Li ) et F (Li ) vues dans les démonstrations ci-dessus restent valables, mais pas pour L = L1 ∩ L2 .

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 ε.

let rec contient_eps e=match e with


| Eps -> true
| S _ -> false
| Plus (a,b) -> contient_eps a || contient_eps b
| Conc (a,b) -> contient_eps a && contient_eps b
| Etoile _ -> true
;;

Svartz Page 163/187


13.6. ÉQUATIONS AUX LANGAGES (HP) Lycée Masséna

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.

let rec union l1 l2=match l1 with


| [] -> l2
| x::q when [Link] x l2 -> union q l2
| x::q -> x::(union q l2)
;;

let rec prod p s=match p with


| [] -> []
| x::q -> ([Link] (function y -> (x,y)) s)@(prod q s)
;;

avec [Link] f t qui produit la liste de f(x) pour x dans la liste t.


On peut maintenant écrire la fonction principale, prenant en entrée une expression rationnelle e et qui renvoie
les ensembles p, s, f ainsi qu’un booléen indiquant si ε est dans le langage dénoté par e. Comme évoqué dans la
remarque 13.55, cette fonction est valable même pour des expressions dénotant des langages non locaux.

let rec calcule e=match e with


| Eps -> [], [], [], true
| S x -> [x], [x], [], false
| Plus (f, g) -> let p1,s1,f1, b1=calcule f and p2,s2,f2,b2=calcule g in
(union p1 p2),(union s1 s2),(union f1 f2), b1 || b2
| Conc (f, g) -> let p1,s1,f1,b1=calcule f and p2,s2,f2,b2=calcule g in begin match b1,b2 with
| false, false -> p1, s2, union f1 (union f2 (prod s1 p2)), false
| false, true -> p1, union s1 s2, union f1 (union f2 (prod s1 p2)), false
| true, false -> union p1 p2, s2, union f1 (union f2 (prod s1 p2)), false
| true, true -> union p1 p2, union s1 s2, union f1 (union f2 (prod s1 p2)), true
end
| Etoile f -> let p, s, f, _=calcule f in p, s, union f (prod s p), true
;;

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.

13.6 Équations aux langages (HP)


Cette section sera très utile dans la suivante, lorsqu’on voudra calculer le langage reconnu par un automate. Tout
repose sur le lemme suivant.

Lemme 13.56 (d’Arden). Soit A et B deux langages de Σ∗ . L’équation L = A · L ∪ B en l’inconnue L admet A∗ · B


comme solution. De plus, c’est la plus petite solution au sens de l’inclusion. Enfin, si ε ∈
/ A, c’est la seule solution.

Démonstration. Il y a trois points à démontrer.


• A∗ B solution. On le vérifie simplement, en effet, avec L = A∗ B, on a :

A · L ∪ B = A+ B ∪ A0 B = (A+ ∪ {ε}) · B = A∗ B = L

Svartz Page 164/187


CHAPITRE 13. LANGAGES ET EXPRESSIONS RATIONNELLES Lycée Masséna

• A∗ B solution minimale pour l’inclusion. Soit L un langage solution de l’équation. Comme B ⊂ A · L ∪ B = L,


L contient B. Une récurrence immédiate montre que L contient Ak B pour tout k ≥ 0. En effet, c’est vrai pour
k = 0 et si la propriété est vérifiée pour l’entier k, on a Ak+1 B = A · Ak B ⊂ A · L ⊂ L. Ainsi L contient
∪+∞ k ∗
k=0 A B = A B.
/ A, L = A∗ B est la seule solution. Supposons ε ∈
• Si ε ∈ / A et raisonnons par l’absurde en supposant que L\A∗ B
est non vide. Considérons un de ses éléments de longueur minimale, noté m. Clairement, m n’est pas dans
B ⊂ A∗ B. Donc m ∈ A · L, et s’écrit uv avec u ∈ A, v ∈ L. Comme u 6= ε car ε ∈ / A, |v| est strictement inférieur
à |m|. Il s’ensuit que v ∈ A∗ B par hypothèse sur m, donc m = uv ∈ A∗ B, absurde. D’où A∗ B est bien la seule
solution.

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

/ An−1,n−1 , le lemme d’Arden implique que Ln−1 = A∗n−1,n−1 · ∪n−2



Comme ε ∈ j=0 An−1,j Lj ∪ Bn−1 . En reportant ce
résultat dans les n − 1 équations restantes, on obtient le système
 
n−2
[
Li =  A0i,j Lj  ∪ Bi0 pour 0 ≤ i ≤ n − 2
j=0

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 .

Svartz Page 165/187


13.6. ÉQUATIONS AUX LANGAGES (HP) Lycée Masséna

Svartz Page 166/187


CHAPITRE 14. AUTOMATES Lycée Masséna

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.

14.2 Automates finis déterministes


14.2.1 Définitions
Remarque 14.2. Les automates au programme sont finis. On verra un peu plus loin dans le cours des automates non
déterministes.
Définition 14.3. Un automate déterministe (AFD en abrégé) est un quintuplet (Σ, Q, q0 , F, δ) où :
— Σ est un alphabet (fini) ;
— Q est un ensemble non vide, dont les éléments sont les états de l’automate ;
— q0 ∈ Q est l’état initial de l’automate ;
— F ⊂ Q est l’ensemble des états finaux ;
— δ est la fonction de transition. C’est une application partielle de Q × Σ → Q, c’est-à-dire que δ(q, a) peut ne pas
être définie pour tout (q, a) (concrètement, c’est donc une application d’un sous-ensemble de Q × Σ dans Q). On
dira que δ(q, a) est un blocage de l’automate si δ(q, a) n’est pas défini.

Svartz Page 167/187


14.2. AUTOMATES FINIS DÉTERMINISTES Lycée Masséna

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.

Exemple 14.4. Voici un autre exemple d’automate :

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}

Définition 14.7. On appelle langage reconnaissable un langage reconnu par un AFD.

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(Σ∗ ).

14.2.2 Équivalence d’automates


Définition 14.8. Deux automates sur un même alphabet Σ sont dits équivalents s’ils reconnaissent le même langage.

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 × Σ∗ .

Proposition 14.10. Un automate est équivalent à un automate complet.


1. Certains auteurs préfèrent une flêche sortante.

Svartz Page 168/187


CHAPITRE 14. AUTOMATES Lycée Masséna

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.

Svartz Page 169/187


14.2. AUTOMATES FINIS DÉTERMINISTES Lycée Masséna

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.

14.2.3 Automates locaux


Définition 14.22. Un automate est dit local si

∀a ∈ Σ, ∃qa ∈ Q, ∀q ∈ Q, δ(q, a) est un blocage ou δ(q, a) = qa

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 .

Svartz Page 170/187


CHAPITRE 14. AUTOMATES Lycée Masséna

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

14.2.4 Les langages reconnaissables sont rationnels (HP)


Théorème 14.27. Soit L un langage reconnaissable. Alors L est rationnel.
Avant de prouver le théorème, commençons par un exemple.
Exemple 14.28. Considérons l’automate A = ({a, b}, Q, q0 , F, δ) représenté ci-dessous.
b

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.

Svartz Page 171/187


14.3. AUTOMATES NON DÉTERMINISTES Lycée Masséna

14.3 Automates non déterministes


Dans un AFD, il y a au plus un chemin étiqueté par un mot depuis un état donné. La notion de non déterminisme
pour un automate signifie qu’il n’y a plus cette unicité du chemin. Ceci se traduit par :
— la possibilité de plusieurs transitions étiquetées par une même lettre depuis un même état ;
— la possibilité d’avoir plusieurs états initiaux.
On verra que cette généralisation ne change pas l’ensemble des langages reconnus. Il y a plusieurs avantages à
l’introduction du non déterminisme :
— d’un point de vue pratique, les automates non déterministes sont faciles à construire, et ils ont souvent moins
d’états que les automates déterministes. On verra comment construire facilement un automate non déterministe
reconnaissant le langage dénoté par une expression rationnelle.
— d’un point de vue théorique, l’introduction du non déterminisme facilite considérablement certaines preuves de
stabilité des langages reconnaissables (par exemple : si L est reconnaissable, le langage t L, constitué des miroirs
des mots de L, est également reconnaissable).
Par contre, il est difficile de déterminer algorithmiquement si un mot est reconnu par un automate non déterministe :
il faudrait examiner tous les chemins possibles étiquetés par le mot. Heureusement, on verra une procédure permettant
de déterminiser un automate non déterministe.

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.

Exemple 14.30. L’automate suivant est un automate fini non déterministe :

a, b a, b

q0 a q1 b q2 a q3

On a par exemple δ(q0 , a) = {q0 , q1 }.

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.

Le lecteur se convaincra rapidement que l’automate précédent reconnaît Σ∗ abaΣ∗ .

Remarque 14.32. Les notions d’automate standard et d’automate émondé s’étendent au cas non déterministe.

Svartz Page 172/187


CHAPITRE 14. AUTOMATES Lycée Masséna

14.3.2 Détérminisation d’un automate non déterministe


On va montrer que du point de vue des langages reconnus, l’introduction du non déterminisme ne change rien,
en donnant un procédé pour construire un automate déterministe équivalent à un automate non déterministe. Cette
construction est la preuve du théorème suivant.
Théorème 14.33. Soit A un automate fini non déterministe. Alors il existe un automate déterministe A0 tel que
L(A) = L(A0 ).
Démonstration. Soit A = (Σ, Q, I, F, δ). On va construire un automate déterministe (dit des parties) reconnaissant
L(A) : ses états sont l’ensemble des parties de Q. Dans l’idée, l’exécution de cet automate avec un mot m revient à
simuler toutes les exécutions possibles de A avec m. Soit donc A0 = (Σ, P(Q), I, F 0 , δ 0 ) défini par :
— l’ensemble des états de A0 est l’ensemble des parties de Q ;
— l’état initial de A0 est I ∈ P(Q), ensemble des états initiaux de A ;
— F 0 = {E ∈ P(Q) | E ∩ F 6= ∅} : l’ensemble des états finaux de A0 est constitué des parties de Q contenant au
moins un état final de A ;
— δ 0 est définie comme suit : δ 0 (E, a) = ∪q∈E δ(q, a).
Notons que A0 est déterministe complet. Pour montrer que A et A0 reconnaissent le même langage, montrons par
récurrence sur |m| la propriété :
Il existe un chemin étiqueté par m, depuis un état de I, vers un état q dans A si et seulement si le chemin dans
A0 étiqueté par m depuis I aboutit à un état E contenant q.
— Si |m| = 0, la propriété est vraie (les deux morceaux sont équivalents à q ∈ I).
— Soit maintenant un mot m = m1 · · · mn de longueur n ≥ 1, et supposns la propriété vraie au rang n − 1.
m m
— Supposons qu’il existe un chemin dans A étiqueté par m d’un état q0 ∈ I vers q. Notons q0 →1 q1 · · · →n qn = q
ce chemin. Par hypothèse de récurrence, le chemin dans A0 étiqueté par m1 · · · mn−1 de I aboutit à un état
E contenant qn−1 . Mais δ(E, mn ) contient alors qn , donc δ 0∗ (I, m) aussi.
m m
— Réciproquement, soit I →1 Q1 · · · →n Qn le chemin dans A0 étiqueté par m, depuis I. Soit q ∈ Qn . Comme
δ 0 (Qn−1 , mn ) contient q, il existe un état qn−1 dans Qn−1 tel que q ∈ δ(qn−1 , mn ). Par hypothèse de
récurrence, il existe un chemin étiqueté par m1 , . . . , mn−1 dans A, depuis un état de I vers qn−1 . On
m
complète ce chemin avec la transition qn−1 →n q pour terminer la preuve.
— Par principe de récurrence, la propriété est démontrée.
D’après la définition des états finaux de A0 , cette propriété implique le résultat : A et A0 reconnaissent le même
langage.
Exemple 14.34. Déterminisons l’automate qui suit :

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.

Svartz Page 173/187


14.3. AUTOMATES NON DÉTERMINISTES Lycée Masséna

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

F 3 δ ∗ (q0 , us) = δ ∗ (ϕ(u), s) = δ ∗ (ϕ(v), s) = δ ∗ (q0 , vs) ∈


/F

ce qui est absurde. Donc ϕ est injective et A possède au moins 2n états.

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

14.3.3 Automate de Glushkov et algorithme de Berry-Sethi


Cette partie est fondamentale, et explicitement au programme. Nous allons voir comment obtenir un automate
reconnaissant le langage dénoté par une expression rationnelle donnée, ce qui montrera l’implication langage rationnel
⇒ langage reconnaissable. On se donne donc e une expression rationnelle.

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).

Linéarisation d’une expression rationnelle


Définition 14.37. Soit e une expression rationnelle sans ∅ ni ε sur un alphabet Σ. Notons k le nombre de lettres de Σ
apparaissant dans e, multiplicité comprise. On se donne un alphabet Σ0 = {c1 , . . . , ck } de taille k. La linéarisation de
e consiste à remplacer chaque lettre apparaîssant dans e par une lettre de Σ0 pour obtenir une expression rationnelle
linéaire e0 de Σ0 , de sorte que chaque lettre de e0 n’apparaîsse qu’une fois.

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.

Svartz Page 174/187


CHAPITRE 14. AUTOMATES Lycée Masséna

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

ϕ(L(e0 )) = ϕ(L(f 0 ) ∪ L(g 0 )) = ϕ(L(f 0 )) ∪ ϕ(L(g 0 )) = L(f ) ∪ L(g) = L(e)

.
— 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

ϕ(L(e0 )) = ϕ(L(f 0 )L(g 0 ))


= {ϕ(m1 m2 ) | (m1 , m2 ) ∈ (L(f 0 ), L(g 0 ))}
= {ϕ(m1 )ϕ(m2 ) | (m1 , m2 ) ∈ (L(f 0 ), L(g 0 ))}
= ϕ(L(f 0 ))ϕ(L(g 0 ))
ϕ(L(e0 )) = L(e)
.
— e = f ∗ : ϕ(L(e0 )) = ϕ(L(f 0∗ )) = ϕ(L(f 0 )∗ ) = {ϕ(m1 . . . mk ) | mi ∈ L(f 0 )} = {ϕ(m1 ) . . . ϕ(mk ) | mi ∈ L(f 0 )} =
L(f )∗ = L(e)

Obtention d’un automate reconnaissant une expression rationnelle


On sait construire des automates associés à des expressions linéaires. La suppression du marquage permet de
construire un automate (a priori non déterminisite) associé à un expression rationnelle quelconque.

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).

Automate de Glushkov et algorithme de Berry-Sethi


Les noms de Victor Glushkov 3 , Richard Berry 4 , et Ravi Sethi 5 sont restés attachés à la procédure d’obtention
d’un automate déterminisite reconnaissant le langage dénoté par une expression rationnelle quelconque.

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.

La procédure d’obtention de l’automate de Glushov s’appelle algorithme de Berry-Sethi. On le résume ci-dessous


(algorithme 14.42).

Exemple
Prenons un exemple complet. Considérons l’expression e = a(ba + b)∗ b sur l’alphabet Σ = {a, b}.

Linéarisation. On linéarise e sur l’alphabet Σ0 = {c1 , c2 , c3 , c4 , c5 } en c1 (c2 c3 + c4 )∗ c5 .


3. Mathématicien et informaticien soviétique.
4. Informaticien français.
5. Informaticien indien.

Svartz Page 175/187


14.3. AUTOMATES NON DÉTERMINISTES Lycée Masséna

Algorithme 14.42 : Algorithme de Berry-Sethi


Entrée : Une expression linéaire e sans ε ni ∅ sur un alphabet Σ.
Sortie : Un automate reconnaissant L(e)
Linéariser e en une expression linéaire e0 sur un alphabet Σ0 ;
Calculer un automate local standard A0 reconnaissant L(e0 ) ;
Supprimer les marques de la linéarisation en rétablissant les lettres de Σ à la place des lettres de Σ0 ;
Déterminiser l’automate obtenu ;
Renvoyer l’automate déterministe calculé.

Calcul des ensembles caractéristiques. On calcule les ensembles P (L(e0 )), S(L(e0 )), F (L(e0 )) :

P (L(e0 )) = {c1 } S(L(e0 )) = {c5 } F (L(e0 )) = {c1 c2 , c1 c4 , c1 c5 , c2 c3 , c3 c2 , c3 c4 , c3 c5 , c4 c2 , c24 , c4 c5 }

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

Svartz Page 176/187


CHAPITRE 14. AUTOMATES Lycée Masséna

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é.

14.3.4 Théorème de Kleene


On peut donc conclure cette section par le théorème suivant, dont une implication a été démontrée par le lemme
d’Arden, l’autre par l’algorithme de Berry-Sethi :
Théorème 14.43. Sur un alphabet Σ donné, les langages rationnels coïncident avec les langages reconnaissables.
Ce théorème est remarquable, car il relie deux propriétés très différentes sur les langages, l’une définie de manière
algébrique (rationnalité) et l’autre procédurale (reconnaissabilité).

14.4 Stabilité des langages rationnels


L’équivalence entre langages rationnels et reconnaissables permet de montrer certaines stabilités de l’ensemble des
langages rationnels, qui auraient été difficiles à établir sans recourir aux automates.

14.4.1 Opérations ensemblistes


Naturellement, l’ensemble des langages rationnels est stable par union, concaténation et passage à l’étoile, parce que
les langages rationnels sont définis ainsi. Mais cet ensemble est stable par beaucoup d’autres opérations ensemblistes.

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.

14.4.2 Preuve alternative à rationnel ⇒ reconnaissable


Pour prouver qu’un langage rationnel est reconnaissable, on peut montrer que les langages reconnaissables sont
stables par union, concaténation et étoile de Kleene. Cela fournit des constructions d’automates qui sont intéressantes
en elles-mêmes. Avant cela, il faut exhiber des automates qui reconnaissent les langages ∅, {ε} et {a} pour a ∈ Σ. En
voici :

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).

Svartz Page 177/187


14.4. STABILITÉ DES LANGAGES RATIONNELS Lycée Masséna

Automate reconnaissant L1 ∪ L2 . On construit très facilement un automate non déterministe reconnaissant


L1 ∪L2 . Il suffit de considérer l’« union disjointe » des deux automates. On obtient ainsi un automate non déterministe.
Plus précisément, on considère A = (Σ, Q1 ∪ Q2 , {q01 , q02 }, F1 ∪ F2 , δ) où la fonction de transition est définie comme
suit :


 {δ1 (q, a)} si q ∈ Q1 et δ1 (q, a) n’est pas un blocage ;
∅ si q ∈ Q1 et δ1 (q, a) est un blocage ;

δ(q, a) =
 {δ2 (q, a)}
 si q ∈ Q2 et δ2 (q, a) n’est pas un blocage ;
∅ si q ∈ Q2 et δ2 (q, a) est un blocage ;

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 .

14.4.3 Préfixes, suffixes, facteurs, sous-mots, miroir...


On termine cette section par le théorème suivant. Pour L un langage sur l’alphabet Σ, on note Pref(L), Suff(L),
Fact(L), SM(L) et t L les langages constitués des préfixes des mots de L, des suffixes des mots de L, des facteurs des
mots de L, des sous-mots des mots de L et enfin des miroirs des mots de L :
t
L = {mk mk−1 · · · m1 | m1 m2 · · · mk ∈ L}

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.

Svartz Page 178/187


CHAPITRE 14. AUTOMATES Lycée Masséna

14.5 Lemme de l’étoile (HP)


On sait maintenant que les langages reconnaissables sont rationnels, mais on ne sait pas déterminer si un langage
donné est reconnaissable. Une condition nécessaire (mais non suffisante) que doit satisfaire un langage pour être
reconnaissable est qu’il satisfasse le lemme de l’étoile, qu’on présente maintenant.

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 :

|uv| ≤ N, |v| ≥ 1 et pour tout k ≥ 0, uv k w ∈ L.

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 .

Exemple 14.54. On donne quelques exemples d’application du lemme.


• Le langage L = {an bn | n ∈ N} n’est pas rationnel. Supposons en effet qu’il vérifie le lemme de l’étoile, donnons
nous un mot de longueur au moins N du langage, avec N l’entier du lemme. On décompose m = uvw comme
dans le lemme. Alors v ne peut contenir à la fois les lettres a et b car sinon on aurait ba comme motif de
uv 2 w ∈ L (absurde). Donc v n’est constitué que de a ou que de b, mais alors uv 2 w contient un nombre différent
de a et de b (absurde de nouveau). Donc L n’est pas rationnel.
• Le langage D des mots de Dyck n’est pas rationnel. En effet, s’il l’était, le précédent le serait aussi car c’est
D ∩ L(a∗ b∗ ).
• Le langage L = {ap | p premier} n’est pas rationnel. Dans le cas contraire le lemme de l’étoile donnerait l’existence
de p et q > 0 tel que ap+qn ∈ L pour tout n ≥ 0. On peut supposer p arbitrairement grand (donc supérieur à 2),
et ap+qp serait dans L, absurde.

14.6 Application à la reconnaissance de motifs. Expressions régulières


étendues.
14.6.1 Reconnaissance de motifs
On s’intéresse au problème de savoir si un mot m est facteur d’une chaîne s. En termes de langages, il s’agit de
savoir si s ∈ L(Σ∗ mΣ∗ ). En général lorsqu’on souhaite répondre à cette question, la chaîne s est grande par rapport
à m. L’algorithme naïf a une complexité O(|m| |s|). On souhaite faire mieux, l’idéal serait de n’avoir à parcourir s
qu’une seule fois, donc un algorithme en O(|s| + coût indépendant de s). Il est facile d’obtenir un tel algorithme à
l’aide d’automates.
6. Dénominations assez explicites, mais à titre personnel je préfère « lemme de l’étoile ».

Svartz Page 179/187


14.6. APPLICATION À LA RECONNAISSANCE DE MOTIFS. EXPRESSIONS RÉGULIÈRES ÉTENDUES.
Lycée Masséna

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

Algorithme de reconnaissance de motifs. Pour tester si m est motif de s, il suffit donc de :


— construire l’automate précédent reconnaissant L(Σ∗ mΣ∗ ) ;
— le déterminiser ;
— exécuter l’automate sur s, pour tester si s ∈ L(Σ∗ mΣ∗ ).

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.

14.6.2 Algorithme KMP


L’algorithme KMP (voir TP) revient à construire directement un automate déterministe à O(|m|) états reconnais-
sant Σ∗ mΣ∗ . Dans la suite, on note P (m) l’ensemble des préfixes du mot m. On considère l’automate déterministe à
|m| + 1 états K = (Σ, P (m), ε, {m}, δ), où la fonction de transition δ est définie comme suit, avec s(v) le plus long
suffixe de v qui est aussi préfixe de m.

m si p = m
δ(p, a) =
s(pa) sinon.
Exemple 14.55. L’automate suivant reconnaît Σ∗ aababΣ∗

b a a, b
a

a a b
ε a aa aab aaba aabab
b a
b

Théorème 14.56. L’automate K reconnaît Σ∗ mΣ∗ .

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.

Svartz Page 180/187


CHAPITRE 14. AUTOMATES Lycée Masséna

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.

14.6.3 Autres problèmes de reconnaissance


On a défini l’ensemble des expressions rationnelles (et les langages rationnels associés), par induction, en autorisant
les suymboles de concaténation, d’union, et l’étoile de Kleene. Les théorèmes de stabilité montre que l’on peut autoriser
d’autres symboles, comme par exemple l’intersection et la différence, sans changer l’ensemble des langages dénotés
par ces expressions : cet ensemble est toujours celui des langages rationnels. On peut parler d’expressions rationnelles
étendues pour les expressions rationnelles construites avec ces nouveaux symboles.
On a donné un moyen pratique de construire des automates associés à ces expressions rationnelles étendues : on
peut donc algorithmiquement tester si un mot appartient à un langage dénoté par une telle expression. Ceci permet
par exemple de répondre aux questions qui suivent :
— lignes d’un texte contenant toutes les voyelles, sauf y ?
— lignes d’un texte contenant père, mère et fils, mais pas fille ?
— etc...

Svartz Page 181/187


14.6. APPLICATION À LA RECONNAISSANCE DE MOTIFS. EXPRESSIONS RÉGULIÈRES ÉTENDUES.
Lycée Masséna

Svartz Page 182/187


CHAPITRE 15. STRUCTURES USUELLES EN OCAML Lycée Masséna

Chapitre 15

Structures usuelles en Ocaml

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.2.1 Fonctions usuelles


Voici les fonctions de usuelles de pile :
— [Link] de type unit -> 'a Stack.t, crée et renvoie une pile initialement vide ;
— Stack.is_empty de type 'a Stack.t -> bool teste si la pile passée en entrée est vide ;
— [Link] de type 'a -> 'a Stack.t -> unit ajoute x à la pile p via l’appel [Link] x p ;
— [Link] de type 'a Stack.t -> 'a supprime et renvoie le sommet de la pile, si celle-ci est non vide ;
— [Link] de type 'a Stack.t -> 'a renvoie le sommet de la pile, si celle-ci est non vide.
L’exception levée si on essaie d’accéder au sommet ou de dépiler une pile vide est [Link].

15.2.2 Fonctions additionnelles


Le module Stack offre la possibilité de réaliser quelques opérations supplémentaires, en voici quelques-unes :
— [Link] de type 'a Stack.t -> int, renvoie le nombre d’éléments de la pile, s’exécute en temps constant.
— [Link] de type 'a Stack.t -> unit, vide la pile passée en entrée ;
— [Link] de type 'a Stack.t -> 'a Stack.t renvoie une copie de la pile.

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.

15.3.1 Fonctions usuelles


Voici les fonctions de usuelles de file :
— [Link] de type unit -> 'a Queue.t, crée et renvoie une file initialement vide ;
— Queue.is_empty de type 'a Queue.t -> bool teste si la file passée en entrée est vide ;
— [Link] de type 'a -> 'a Queue.t -> unit ajoute x à la file f via l’appel [Link] x f ;

Svartz Page 183/187


15.4. DICTIONNAIRES Lycée Masséna

— [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.3.2 Fonctions additionnelles


De même que pour les piles, le module Queue possède les fonctions [Link], [Link] et [Link].
Signalons aussi [Link], de type 'a Queue.t -> 'a renvoyant l’élément en tête de file sans le supprimer.

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.

15.5 Files de priorité


Curieusement, la bibliothèque standard de Ocaml ne propose pas d’implémentation de file de priorité. En voici
une (file de priorité min !), écrite par mes soins, faisant usage d’un tableau redimensionnable (également implémenté),
utilisé comme un tas (min), et d’une table de hachage pour associer à chaque élément présent dans la file de priorité
sa position dans le tas, ce qui permet de modifier sa priorité (utile pour l’algorithme de Dijkstra, par exemple !).

module type RESIZABLEARRAY_SIG =


sig
type 'a t
val create : unit -> 'a t
val is_empty : 'a t -> bool
val length : 'a t -> int
val get : 'a t -> int -> 'a
val set : 'a t -> int -> 'a -> unit
val add : 'a t -> 'a -> unit
exception ResizableArray_is_empty
val pop : 'a t -> 'a
end;;

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)

Svartz Page 184/187


CHAPITRE 15. STRUCTURES USUELLES EN OCAML Lycée Masséna

let set a i x = [Link].(i) <- x


let add a x = match [Link] = [Link] with
| true -> let e2=[Link] (2*[Link] + 1) x in
for i=0 to [Link] - 1 do
e2.(i) <- [Link].(i)
done ;
[Link] <- [Link] + 1 ;
[Link] <- 2*[Link] + 1 ;
[Link] <- e2
| false -> [Link].([Link]) <- x ; [Link] <- [Link] + 1
exception ResizableArray_is_empty
let pop a = match [Link] with
| 0 -> raise ResizableArray_is_empty
| _ -> [Link] <- [Link] - 1 ; [Link].([Link])
end : RESIZABLEARRAY_SIG)
;;

module type PRIOQUEUE_SIG =


sig
type 'a t
val create : unit -> 'a t
val is_empty : 'a t -> bool
exception PrioQueue_is_empty
exception AlreadyMemberInPrioQueue
exception NotMemberInPrioQueue
val add : 'a t -> 'a -> int -> unit
val remove_prio : 'a t -> 'a
val change_prio : 'a t -> 'a -> int -> unit
val mem : 'a t -> 'a -> bool
end;;

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]

Svartz Page 185/187


15.6. CRÉER UN MODULE EN OCAML Lycée Masséna

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

15.6 Créer un module en Ocaml


On explique ici brièvement comment créer un module en Ocaml, essentiellement on va expliquer le module précé-
dent. La déclaration d’un module se fait de la façon suivante :

module nom_du_module = struct ... end ;;

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 :

module type nom_du_module_de_signature = sig ... end ;;

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 :

module nom_du_module = (struct ... end : nom_du_module_de_signature) ;;

Voici un exemple :

Svartz Page 186/187


CHAPITRE 15. STRUCTURES USUELLES EN OCAML Lycée Masséna

# 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.

Svartz Page 187/187

Vous aimerez peut-être aussi