Root
Root
Root
Roberto M. Amadio
et d’algorithmique
Roberto M. Amadio
Université de Paris
15 décembre 2020
2
Table des matières
Préface 7
Notation 9
1 Introduction 11
1.1 Algorithmes et programmes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.2 Structure et interprétation d’un programme C . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
1.3 Compilation, exécution et erreurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
2 Types atomiques 21
2.1 Entiers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.2 Booléens . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
2.3 Flottants . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
2.4 Entrées-sorties . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
2.5 Conversions implicites et explicites . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
3 Contrôle 29
3.1 Commandes de base et séquentialisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.2 Branchement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
3.3 Boucles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.4 Rupture du contrôle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.5 Aiguillage switch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
3.6 Énumération de constantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
4 Fonctions 35
4.1 Appel et retour d’une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
4.2 Portée lexicale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
4.3 Argument-résultat, Entrée-sortie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.4 Méthode de Newton-Raphson . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.5 Intégration numérique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.6 Conversion binaire-décimal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
5 Fonctions récursives 43
5.1 Évaluation de polynômes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
5.2 Tour d’Hanoı̈ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
5.3 Suite de Fibonacci . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
6 Tableaux 49
6.1 Déclaration et manipulation de tableaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
6.2 Passage de tableaux en argument . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
6.3 Primalité et factorisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
6.4 Tableaux à plusieurs dimensions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
3
4
7 Tri et permutations 55
7.1 Tri à bulles et par insértion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
7.2 Tri par fusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
7.3 Permutations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
9 Pointeurs 69
9.1 Pointeurs de variables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
9.2 Pointeurs de tableaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
9.3 Pointeurs de char . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
9.4 Fonctions de fonctions et pointeurs de fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
9.5 Fonctions génériques et pointeurs vers void . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
9.6 Pointeurs de fichiers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
11 Piles et queues 83
11.1 Piles et queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
11.2 Modularisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
11.3 Applications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
13 Complexité asymptotique 97
13.1 O-notation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
13.2 Opérations arithmétiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
13.3 Tests de correction et de performance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
13.4 Variations sur la notion de complexité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103
23 Graphes 163
23.1 Représentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
23.2 Visite d’un graphe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
23.3 Visite en largeur et distance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
23.4 Visite en profondeur et tri topologique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Bibliographie 191
Index 193
6
A Problèmes 197
A.1 Chiffrement par permutation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
A.2 Chaı̂nes additives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 198
A.3 Affectation stable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 198
A.4 Remplissages de grilles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 199
A.5 Tournoi à élimination directe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 201
A.6 Motifs et empreintes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 202
A.7 Majorité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203
A.8 Un tas en dimension 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203
A.9 Recherche des deux points les plus rapprochés . . . . . . . . . . . . . . . . . . . . . . . . . . . . 204
A.10 Arbres binaires de recherche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205
A.11 Calcul du centre d’un arbre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205
A.12 Optimisation de requêtes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 206
A.13 Plus longue sous-séquence croissante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 207
A.14 Distance d’édition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 207
A.15 Clôture transitive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 208
A.16 Diagrammes de décision binaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 209
A.17 Algorithme de Kruskal pour le calcul d’un arbre de recouvrement . . . . . . . . . . . . . . . . . 210
Préface
On trouve de nombreux livres, notes de cours, vidéos,. . . qui proposent une introduction
adéquate à la programmation et à l’algorithmique. Dans ce sens ces notes de cours sont
redondantes ; elles n’ont d’autre ambition que de fournir une trace synthétique des sujets traités
dans un cours d’introduction à la programmation (en C) et à l’algorithmique qui s’adresse
aux étudiants d’un Master en mathématiques de l’Université de Paris. Des sous-ensembles
de ces notes ont été utilisés dans le cadre d’un cours d’initiation à la programmation pour
une classe préparatoire aux écoles d’ingénieurs et d’un cours d’introduction à l’algorithmique
pour une école d’ingénieurs.
Les chapitres 1–11 de ces notes prennent la forme d’une visite guidée des structures princi-
pales de la programmation (en C) et d’un certain nombre d’exemples d’algorithmes classiques
qui illustrent leur utilisation. On s’attend à que le lecteur teste, modifie et améliore les pro-
grammes discutés en cours. Ces chapitres ne sont ni un manuel de référence ni une introduction
systématique au langage C (voir, par exemple, le manuel rédigé par les concepteurs du langage
C [KR14]). Dans le commentaire aux exemples, on esquissera quelques principes de génie logi-
ciel qu’il convient de suivre dans la conception de programmes (de taille modeste). Le lecteur
intéressé pourra consulter, par exemple, [KP17] pour une discussion plus étendue.
Les chapitres 12–27 se focalisent sur des notions d’algorithmique plus avancées et s’arti-
culent autour de 3 thématiques.
— La présentation d’un certain nombre de structures de données : tas, arbres, listes à
enjambements, tables de hachage, graphes,. . .
— L’introduction de techniques de conception d’algorithmes : diviser pour régner, pro-
grammation glutonne et dynamique, approche probabiliste, programmation linéaire.
— La description de techniques d’analyse : la notion de complexité asymptotique dans le
pire cas et en moyenne et la solution de relations de récurrence.
Ces chapitres s’appuient sur un certain nombre de notions mathématiques qui sont nor-
malement couvertes dans un premier cycle scientifique. On suppose notamment des notions
élémentaires de théorie des groupes, d’algèbre linéaire, d’arithmétique modulaire et de calcul
des probabilités. Pour les aspects algorithmiques, la lecture du livre [CLRS09] est fortement
conseillée et le livre [Sho05] permet d’approfondir les notions mathématiques évoquées.
7
8 Préface
Notation
Ensembles
∅ ensemble vide
2 = {0, 1} valeurs booléennes
N nombres naturels
Z nombres entiers
Q nombres rationnels
R nombres réels
∪,
S∩ T union, intersection de deux ensembles
, union, intersection d’une famille d’ensembles
c
X complémentaire de X
YX fonctions de X dans Y
P(X) sous-ensembles de X
Pfin (X) sous-ensembles finis de X
]X cardinal de X
R∗ clôture réflexive et transitive d’une relation R
Arithmétique
Zn entiers modulo n
Z∗n groupe multiplicatif des entiers modulo n
≡ congruence
a/b quotient division entière
a mod b reste de la division entière (ou module)
Algèbre linéaire
A, B, . . . matrices
adj (A) matrice adjointe
det(A) déterminant
A−1 matrice inverse
Probabilité
9
10 Notation
Algorithmique
Introduction
Exemple 1 On dénote par {0, 1}∗ l’ensemble des suites finies de 0 ou 1 (y compris la suite
vide). Prenons D = D0 = {0, 1}∗ et associons à toute suite w = bn · · · b0 ∈ D un nombre
naturel hwi défini par :
hwi = Σi=0,...,n bi · 2i .
1. Ici par fonction on entend une relation binaire sur D × D0 telle que pour tout x ∈ D il existe au plus un
y ∈ D0 tel que (x, y) est dans la relation.
11
12 Introduction
Par exemple :
h01010i = 0 · 20 + 1 · 21 + 0 · 22 + 1 · 23 + 0 · 24 = 2 + 8 = 10 .
La suite w représente donc un nombre naturel en base 2. Tout nombre naturel peut être
représenté de cette façon mais la représentation n’est pas unique. Par exemple :
Le premier algorithme prend la suite w en entrée, la voit comme un nombre binaire en base 2
et multiplie le nombre par lui même en adaptant à la base 2 l’algorithme pour la multiplication
appris en primaire. Le deuxième algorithme convertit la suite w dans un nombre en base 10,
multiplie ce nombre par lui même et enfin retrouve sa représentation binaire (on verra dans
la suite du cours comment effectuer ces conversions). On peut donc associer deux algorithmes
différents à la même fonction et plus en général on peut montrer que pour tout algorithme il
y a un nombre dénombrable d’algorithmes qui sont équivalents dans le sens qu’ils calculent la
même fonction.
Dans notre exemple, on a utilisé l’intuition des calculs appris en primaire pour spécifier
l’algorithme (la méthode de calcul). Le lecteur sait que l’on peut effectuer les opérations
arithmétiques sur des nombres de taille arbitraire à condition de disposer de suffisamment de
papier, de crayons et de temps. Plus en général, on peut imaginer des ‘machines’ qui savent
manipuler des chiffres, stocker des informations et les récupérer. Un programme est alors un
algorithme qui est formalisé de façon à pouvoir être exécuté par une telle ‘machine’. 3
Pour aller vers un programme, il faut préciser une représentation des nombres entiers et des
nombres réels. Les langages de programmation disposent de types prédéfinis. En particulier,
en C on peut utiliser, par exemple, le type int pour représenter les entiers et le type float
pour représenter les réels. Dans ces cas, un nombre est représenté avec un nombre limité de
bits (typiquement avec 32 ou 64 bits) ; il faut donc savoir que les opérations arithmétiques
dans le contexte de la programmation peuvent provoquer des débordements, et dans les cas
des réels des approximations (erreurs d’arrondi) aussi. Par ailleurs, dans les langages de
programmation on peut représenter les vecteurs par des tableaux (qu’on étudiera, dans les
chapitre 6). Ainsi un programme C qui raffine l’algorithme ci-dessus pourrait être le suivant.
1 double produit_scalaire ( double x [] , double y [] , int n ){
2 double s =0;
3 int i ;
4 for ( i =0; i < n ; i ++){
5 s = s + x [ i ]* y [ i ];}
6 return s ;}
avec un nombre d’opérations élémentaires qui est de l’ordre de n2 . On dit que la complexité
de l’algorithme est (la fonction) quadratique. Plus en général, un algorithme polynomial est
une méthode de calcul tel qu’il existe un polynôme p(n) avec la propriété que la méthode sur
une entrée de taille n effectue un nombre d’opérations élémentaires borné par p(n). On ap-
pelle théorie de la complexité la branche de l’informatique théorique qui cherche à classifier
la complexité des problèmes. Dans ce contexte, dans les années 1970 on a formulé le problème
ouvert qui est probablement le plus important et certainement le plus célèbre de l’informatique.
D’une certaine façon, la question est de savoir si trouver une solution d’un problème est beau-
coup plus difficile que de vérifier sa correction. L’intuition suggère une réponse positive mais
dans un certain cadre on est incapable de prouver le bien fondé de cette intuition. Le cadre
est le suivant : existe-t-il un algorithme qui prend en entrée une formule A du calcul propo-
sitionnel et qui décide dans un temps polynomial dans la taille de A si A est satisfaisable ?
Par exemple, si A = (not(x) or y) and (x or not(y)) alors on peut satisfaire la formule avec
l’affectation v(x) = true et v(y) = true. Par contre, le lecteur peut vérifier que la formule
B = (not(x) or y) and (x or not(y)) and (not(x) or not(y)) and (not(x) or y) n’est pas satisfai-
sable. Pour toute affectation v, il est facile de vérifier si une formule A est vraie par rapport à
l’affectation. Par ailleurs, pour savoir si une formule est satisfaisable on peut générer toutes
les affectations et vérifier s’il y en a une qui satisfait la formule. Malheureusement, cette
méthode n’est pas efficace car pour une formule avec n variables il faut considérer 2n affecta-
tions (la fonction exponentielle 2n croit beaucoup plus vite que n’importe quel polynôme). La
question ouverte est donc de trouver un algorithme polynomial qui nous permet de décider si
une formule est satisfaisable ou de montrer qu’un tel algorithme n’existe pas. 4
Le langage C
Ce qui suit est une description à haut niveau du langage C qui suppose un lecteur qui a
déjà une certaine expérience de programmation. A défaut, on retiendra un certain nombre de
termes techniques dont la signification deviendra plus claire dans la suite du cours.
Le langage C a été conçu autour de 1970 dans le but d’écrire un système d’exploitation
(qui deviendra le système Unix) en utilisant C plutôt qu’un langage assembleur, ce qui est
bénéfique pour la portabilité du système. Il s’agit d’un langage impératif dans le style des
langages Algol et Pascal. Le calcul est donc organisé autour de l’exécution de commandes
qui modifient la mémoire. Il se distingue de ses prédécesseurs par la possibilité d’effectuer
des opérations de bas niveau sur la mémoire (arithmétique de pointeurs) ; ce qui est une
source potentielle d’efficacité et d’erreurs. Par rapport à ses successeurs (C++, Java,. . .),
on notera l’absence d’un mécanisme pour combiner types de données et opérations et d’un
système automatique de récupération de mémoire (on dit aussi ramasse miettes ou garbage
collector, en anglais).
4. On dit aussi que le problème est de savoir si la classe NP est identique à la classe P des problèmes qui
admettent un algorithme polynomial. Intuitivement, la classe NP est la classe des problèmes dont la solution
peut être vérifiée en temps polynomial. A priori NP contient P et le problème est de savoir si l’inclusion est
stricte.
Introduction 15
Syntaxe et sémantique
En général, dans un langage la syntaxe est un ensemble de règles qui permettent de
produire des phrases admissibles du langage et la sémantique est une façon d’attacher une
signification aux phrases admissibles du langage.
Dans le cas des langages de programmation, on a besoin de règles pour écrire des pro-
grammes qui seront acceptés par la machine et aussi d’une méthode pour déterminer la
sémantique à savoir la fonction calculée par le programme. On aura l’occasion de revenir
sur les détails de la syntaxe dans la suite du cours. Pour l’instant, on souhaite esquisser une
méthode pour calculer le comportement d’un programme (sa sémantique).
En première approximation, la sémantique d’un programme C (et plus en général d’un lan-
gage impératif) s’articule autour de 6 concepts : mémoire, environnement, variable, fonction,
bloc d’activation (frame en anglais) et pile de blocs d’activation.
Pour décrire l’exécution d’un programme qui est essentiellement composé d’une seule
fonction qui ne s’appelle pas récursivement on peut se concentrer sur les premiers 3 concepts
et sur la notion de compteur ordinal ; un compteur ordinal est une composante d’un bloc
d’activation qui contient l’adresse de la prochaine instruction de la fonction qu’il faut exécuter.
Mémoire Une fonction qui associe des valeurs aux adresses de mémoire. Il est possible
de :
— allouer une valeur à une nouvelle adresse,
— lire le contenu d’une adresse de mémoire,
— modifier le contenu d’une adresse de mémoire,
— récupérer une adresse de mémoire pour la réutiliser.
Environnement Dans un langage de programmation de ‘haut niveau’ on donne des noms
symboliques aux entités qu’on manipule (une constante, une variable, une fonction,. . .)
Un environnement est aussi une fonction qui associe à chaque nom du programme une
entité (une valeur, une adresse mémoire, un segment de code,. . .) Environnement et
mémoire sont liés. Par exemple, dans une commande de la forme x = 10, on associe
au nom x une nouvelle adresse de mémoire ` (modification de l’environnement) et à
l’adresse de mémoire ` la valeur 10 (modification de la mémoire).
Variable Un nom qui est associé à une adresse de mémoire (on dit aussi location ou
référence) qui contient éventuellement une valeur. Dans un langage impératif comme C
la valeur peut être modifiée plusieurs fois pendant l’exécution. Il ne faut pas confondre
les variables au sens mathématique avec les variables au sens informatique.
Exemple 4 On illustre l’utilisation de ces concepts dans l’exemple suivant d’un programme
C qui calcule le plus grand commun diviseur (pgcd) d’après l’algorithme d’Euclide. On rappelle
que si a, b sont des entiers avec b > 0 alors ils existent uniques q et r tels que 0 ≤ r < b et
a=b·q+r .
On appelle q le quotient ou la division entière de a par b et r le reste qu’on dénote aussi par
a mod b. En supposant a, b entiers avec b > 0 on a la propriété suivante :
b si a mod b = 0
pgcd (a, b) =
pgcd (b, a mod b) autrement.
En C, l’opération de quotient est dénotée par / et celle de reste par %. Voici un programme
pour le pgcd. A noter qu’en C, on peut avoir −6/4 = −1 et −6%4 = −2 ; si l’on veut que
l’expression a%b calcule le module il convient de supposer que a ≥ 0.
1 # include < stdio .h >
2 void lire ( int * p ){
3 printf (" Entrez un entier positif :");
4 scanf ("% d " , p );}
5 int pgcd ( int a , int b ){
6 int mod = a % b ;
7 if ( mod ==0){
Introduction 17
8 return b ;}
9 else {
10 return pgcd (b , mod );}}
11 void main (){
12 int a , b ;
13 lire (& a );
14 lire (& b );
15 int resultat ;
16 resultat = pgcd (a , b );
17 printf (" le pgcd est % d \ n " , resultat );}
Ce programme commence avec une directive au compilateur pour inclure les fonctions de
bibliothèque contenues dans stdio.h. Parmi ces fonctions, on trouve les fonctions printf et
scanf qu’on utilisera dans le cours pour imprimer et lire des valeurs.
Le programme comporte 3 fonctions : lire, pgcd et main. L’interface (ou en tête) de chaque
fonction précise le type du résultat et les noms et types des arguments de la fonction. Par
exemple, la fonction pgcd rend un résultat de type int et attend deux arguments de type int
dont les noms sont a et b. La table 1.1 décrit l’exécution du programme en supposant que
l’utilisateur rentre les valeurs 6 et 4. Chaque instant du calcul est décrit par la pile des blocs
d’activation et le contenu de la mémoire.
La table 1.1 décrit l’exécution du programme en supposant que l’utilisateur rentre les
valeurs 6 et 4. Comme il s’agit d’un programme très simple on n’a pas besoin d’expliciter les
locations de mémoire associées aux variables.
Remarque 1 Variables et fonctions sont des entités qu’on peut associer à certaines portions
du texte (la syntaxe) du programme. Par opposition, mémoire, environnement, bloc d’ac-
tivation et contrôle sont des entités qu’on a crée pour expliquer et prévoir l’exécution du
18 Introduction
programme (la sémantique). 5 Pendant l’exécution peuvent coexister plusieurs instances (ou
avatars) du même objet syntaxique. Par exemple, on peut avoir plusieurs instances de la
fonction pgcd et des variables a,b,mod. Aussi, on peut avoir des situations d’homonymie.
Par exemple, a est une variable de main et un paramètre (un argument) de pgcd. On élimine
toute ambiguı̈té en supposant qu’on s’adresse toujours au a qui est le plus ‘proche’.
Tout programme C contient au moins une fonction dont le nom est main (ligne 1). Le
calcul commence avec un appel à cette fonction et termine quand cette fonction termine son
exécution. Par défaut, la fonction main ne prend pas d’arguments et rend un entier 0 comme
résultat (ligne 3). Par convention, l’entier 0 indique une terminaison normale. Certains com-
pilateurs permettent aussi un résultat de type void (le type vide) et dans ce cas on marque la
fin de la fonction avec une commande return. La commande qui imprime Bonjour est à la ligne
2. La fonction printf est une fonction de la bibliothèque stdio et pour l’utiliser il faut ajouter
au programme ci-dessus une directive ]includehstdio.hi. Le texte à imprimer est compris entre
guillemets. Dans l’exemple, après le mot Bonjour on imprime aussi un saut de ligne qui est
dénoté par le caractère \n. On notera que chaque commande dans le corps de la fonction est
suivie par un point virgule.
Tout ce qui suit le symbole // et se trouve dans la même ligne est un commentaire. Un
commentaire devrait aider à comprendre le comportement d’un programme mais n’affecte en
rien son exécution. Si l’on veut écrire un texte de commentaire sur plusieurs lignes on utilisera
la notation :
1 /* texte
2 de commentaire ici */
L’utilisateur commence par écrire à l’aide d’un éditeur de texte (par exemple emacs) le
programme dans un fichier dont le nom termine par .c. Par exemple : Bonjour.c. Pour compiler
avec le compilateur gcc on écrira une commande :
cc Bonjour.c
Par défaut, le code exécutable généré est mémorisé dans le fichier a.out. Pour l’exécuter, on
lance la commande :
./a.out
On peut modifier le nom du fichier qui contient l’exécutable en utilisant l’option -o. Par
exemple, avec la commande :
cc − o Bonjour Bonjour.c
En programmation, on est confronté à des erreurs qu’on peut classifier en deux catégories.
Types atomiques
Les valeurs manipulées par un programme sont classifiées dans un certain nombre de types.
Le type d’une valeur va déterminer les opérations qu’on peut lui appliquer (ou pas).
Tout langage comporte un certain nombre de types prédéfinis et atomiques (ou indivi-
sibles). Parmi ces types, le langage C propose les types suivants :
— short, int ou long pour les nombres entiers,
— char pour les caractères ASCII (8 bits) ; par exemple, ‘a’, ‘b’, ‘@’ sont des valeurs de
type char,
— bool pour les valeurs booléennes (true ou false),
— float ou double pour les nombres flottants (une approximation des nombres réels).
Remarque 2 En fonction de la version de C qu’on utilise, pour avoir le type booléen il faut
ajouter la directive : ]include < stdbool.h >. Dans toutes les versions de C, on peut codifier
false par l’entier 0 et true par tout entier différent de 0.
2.1 Entiers
Représentation
Soit B ≥ 2 un nombre naturel qu’on appelle base. On dénote par d, d0 , . . . les chiffres en
base B. Typiquement, si B = 2 les chiffres sont 0, 1, si B = 10 les chiffres sont 0, 1, 2, . . . , 9 et
si B = 16 les chiffres sont 0, 1, 2 . . . , 9, A, B, C, D, E, F . Dans la suite on abusera la notation
en ne faisant pas de différence entre un chiffre et le nombre naturel qui lui est associé. Par
exemple, pour la base B = 16, C est un chiffre et il est aussi un nombre qu’on représente en
base 10 par 12.
Proposition 1 Pour toute base B ≥ 2 et pour tout n > 0 nombre naturel positif ils existent
uniques ` ≥ 0 et d` , . . . , d0 ∈ {0, . . . , B − 1} tels que d` 6= 0 et
n = Σi=0,...,` di · B i . (2.1)
Preuve. Existence. Pour trouver la suite il suffit d’itérer l’opération de division et reste. Soit
n0 = n. Tant que ni > 0 on calcule le quotient et le reste de la division par la base B :
ni = ni+1 · B + di .
21
22 Types atomiques
On obtient ainsi la représentation de n à partir du chiffre le moins significatif (le plus à droite).
On a donc :
n0 = n1 · B + d0
n1 = n2 · B + d1
··· = ···
n`−1 = n` · B + d`−1
n` = 0 · B + d`
et on peut vérifier :
n = n0 = (· · · ((d` · B) + d`−1 ) · B + · · · + d0 )
= Σi=0...` di · B i .
Unicité. On remarque que Σi=0,...,k di · B i < B k+1 . Il est ensuite facile de vérifer que deux
suites différentes d` , . . . , d0 et d0` , . . . , d00 ne peuvent pas représenter le même nombre. 2
En pratique, on a l’abitude de manipuler les nombres en base 10. Les deux questions qu’on
se pose en priorité sont donc :
— Comment trouver la représentation en base 10 d’un nombre représenté dans une autre
base ?
— Comment trouver la représentation en base B d’un nombre représenté en base 10 ?
Pour repondre à la première question il suffit d’appliquer la formule (2.1). Par exemple,
le nombre 101 en base 2 a comme valeur :
1 · 22 + 0 · 21 + 1 · 20 = 5 .
Pour ce qui est de la deuxième question, on applique la méthode de division itérée évoquée
dans la preuve de la proposition 1. Par exemple, pour convertir un décimal en binaire on itère
l’opération de quotient par 2 et reste :
Donc :
19 = 2 · (2 · (2 · (2 · (2 · 0 + 1) + 0) + 0) + 1) + 1
= 24 · 1 + 23 · 0 + 22 · 0 + 21 · 1 + 20 · 1
La représentation de 19 en base 2 est 10011.
Les entiers en C
La majorité des langages de programmation prevoient des types qui permettent de représenter
un nombre borné à priori d’entiers (typiquement des entiers sur 8, 16, 32, 64,. . . bits). En
C on dispose notamment des types int et long qui utilisent typiquement 32 et 64 bits. Par
ailleurs, certains langages disposent de bibliothèques pour la représentation de ‘grands entiers’
(typiquement des entiers avec de l’ordre de 103 chiffres). Sur les valeurs de type int ou long on
dispose des opérations arithmétiques suivantes : + pour l’addition, − pour la soustraction, ∗
pour la multiplication, \ pour la division entière, % pour le reste de la division entière.
Types atomiques 23
2.2 Booléens
En C on dénote les valeurs booléennes par true et false (ou alors par un nombre entier
différent de 0 et par 0, respectivement). Sur ces valeurs on dispose d’opérateurs logiques
standard && (and), || (or) et ! (not) dont on rappelle le comportement :
x y not(y) and (x , y) or (x , y)
false false true false false
false true false false true
true false false true
true true true true
Il est facile de montrer que ces opérateurs suffisent à exprimer toute fonction qu’on pour-
rait définir sur des valeurs booléennes. En particulier, on peut exprimer d’autres opérateurs
logiques binaires comme l’implication logique, l’équivalence logique, le ou exclusif,. . .
Les prédicats de comparaison (égalité ==, différence ! =, plus petit que <, plus grand que
>, plus petit ou égal <=,. . .) retournent une valeur booléenne. Typiquement on utilise ces
prédicats pour écrire des conditions logiques qui vont déterminer la suite du calcul. Bien sûr,
les conditions logiques peuvent être combinées à l’aide d’opérateurs logiques. Par exemple, on
peut écrire :
(x == y) && (x < z + 5 || (x == y + z)) .
Remarque 3 En C, ainsi que dans d’autres langages, l’évaluation d’une condition logique se
fait de gauche à droite et de façon paresseuse, c’est-à-dire dès qu’on a déterminé la valeur
logique de la condition on omet d’évaluer les conditions qui suivent. Ainsi, en C, on peut
écrire la condition logique :
not(x == 0)&& (y/x == 3) (2.2)
qui ne produit pas d’erreur même si x est égal à 0. En effet, si x est égal à 0 alors la première
condition not(x == 0) est fausse et ceci suffit à conclure que la condition logique est fausse.
Le problème avec ce raisonnement est qu’en C une expression logique peut être vraie, fausse,
produire une erreur, produire un effet de bord (par exemple lire une valeur) et même faire
boucler le programme. Il en suit que la conjonction en C n’est pas commutative. Par exemple,
la condition :
(y/x == 3) && not(x == 0) (2.3)
n’est pas équivalente à la condition (2.2) ci-dessus.
2.3 Flottants
Représentation
Le proposition 1 sur la représentation des nombres entiers se généralise aux nombres réels.
Proposition 2 Soit B ≥ 2 et x > 0 nombre réel. Alors ils existent uniques un nombre entier
e (l’exposant) et une suite de chiffres {di | i ≥ 0} en base B (la mantisse) tels que (i) d0 6= 0,
(ii) pour tout i ≥ 1 existe j > i tel que dj 6= (B − 1) et (iii) x = B e · (Σi≥0 di · B −i ).
En utilisant les propriétés des series géométriques on vérifie les propriétés suivantes :
(1) 1 = 2−0 ≤ Σi≥0 di · 2−i < 2, (2) Σi≥s di · 2−i < 2−s+1 (s ≥ 1).
Pour tout x > 0 nombre réel positif, il existe unique e nombre entier tel que :
2e ≤ x < 2e+1 .
Si on pose h = x/2e on a 1 ≤ h < 2. Il reste maintenant à montrer que pour un tel h il existe
une suite unique d0 , d1 , . . . avec les propriétés (2.4) et telle que : h = Σi≥0 di · 2−i .
Les chiffres d0 , d1 , . . . sont détérminées de façon itérative. Au premier pas, si h = 2−0 on
termine avec d0 = 1 et di = 0 pour i ≥ 1. Sinon, on cherche l’unique d1 tel que :
En pratique, il est bien plus intéressant de contrôler l’erreur relative que l’erreur absolue !
Par exemple, supposons x = 100.11 et x e = 100.1 alors l’erreur absolue est 10−2 et l’erreur
−4
relative d’environ 10 . D’autre part si x = 0.11 et x e = 0.1 alors l’erreur absolue est toujours
−2
10 mais l’erreur relative est d’environ 10 . −1
Soit F fini (ce qui est le cas par exemple en double précision), soient f − et f + le plus
petit et le plus grand nombre flottant positif dans F. Soit maintenant x > 0 (le cas x < 0 est
symétrique). Que peut-on dire sur l’erreur absolue et relative d’une approximation de x dans
F ? On distingue 3 cas.
1. Si x < f − et on pose x
e = 0 on a :
e| = x < f − (borne sur l’erreur absolue), x−e
x
|x − x x = 1 (erreur relative).
Remarque 4 On pourrait penser qu’une erreur relative de 2−52 est négligeable (2−52 ≈
2 · 10−16 ). En particulier, si on se place dans le cadre de mésures physiques une erreur
relative de 2−52 est très probablement négligeable par rapport à l’erreur de mésuration. Le
problème est que les fonctions mises en oeuvre pour approcher les fonctions mathématiques
usuelles (opérations arithmétiques, extraction de racine carrée, fonctions trigonométriques,
logarithme,. . .) induisent aussi des erreurs et que ces erreurs se propagent et peuvent s’accu-
muler jusqu’à rendre le résultat d’un calcul sur les flottants non-significatif.
1. Ici on prend l’erreur rélative comme une valeur non-négative. Dans d’autres contextes, on peut aussi
x − x)/x ce qui permet de dire que x
définir l’erreur relative comme le nombre = (e e = (1 + ) · x.
26 Types atomiques
Dans la première formulation, le numérateur tend à 0 et provoque des erreurs de calcul signi-
ficatifs pour x ≥ 1010 . Le lecteur peut tester le programme suivant (en compilant avec l’option
-lm).
1 int main (){
2 float x ;
3 printf (" Input ?");
4 scanf ("% f " , & x );
5 printf (" fonction 1\ n ");
6 float y = x * ( sqrt ( x +1) - sqrt ( x ));
7 printf ("% f \ n " , y );
8 printf (" fonction 2\ n ");
9 y = x /( sqrt ( x +1) + sqrt ( x ));
10 printf ("% f \ n " , y );
11 return 0;}
2.4 Entrées-sorties
On utilisera en priorité printf pour écrire une valeur à l’écran et scanf pour lire une valeur
de l’écran. On verra plus tard (section 9.6) que des variantes de ces commandes permettent
aussi de lire/écrire des fichiers. Voici deux exemples d’utilisation des commandes printf et
scanf.
1 printf (" x =% d " ,4); // imprime : x =4
2 scanf ("% d " ,& x ); // lit et sauve un entier dans x
On remarquera la présence des symboles %d. Dans le cas de la commande printf, il faut
interpréter ces symboles comme un entier dont la valeur doit être déterminée en évaluant
l’expression suivante qu’on passe en argument à printf. L’expression 4 ayant comme valeur
l’entier 4, l’impression de x=%d produit en effet l’impression des caractères x=4. Dans le cas de
la commande scanf, on interprète les symboles %d comme un entier rentré par l’utilisateur qui
doit être mémorisé à une adresse spécifiée dans l’expression qu’on passe en argument à scanf.
Dans l’exemple, il s’agit de l’adresse de la variable x. On notera l’introduction d’un nouveau
opérateur de déréférencement & qui sert à déterminer l’adresse associée à une variable. Il
s’agit d’un cas particulier de pointeur dont on examinera l’utilisation dans le chapitre 9.
Les symboles %d servent à lire/écrire des valeurs de type entier. Pour manipuler des
caractères on utilise les symboles %c et pour des flottants les symboles %f ou %lf. Par ailleurs,
une commande printf (ou scanf) peut contenir plusieurs occurrences de ces symboles et dans
ce cas pour chaque occurrence il faut prévoir une expression (ou une adresse de mémoire)
d’un type compatible. Par exemple, la commande :
1 printf ("\% d : % f " , 3 , 455.45);
va imprimer un entier et un flottant de la façon suivante :
3 : 455.45
Exemple 9 Voici un petit programme qui illustre l’introduction de variables des différents
types primitifs, la forme des valeurs de ces types et les directives utilisées pour les imprimer
avec la commande printf.
1 int main () {
2 char x1 = ’a ’; printf ("% c \ n " , x1 );
3 short x2 =2754; printf ("% d \ n " , x2 );
4 int x3 =333333; printf ("% d \ n " , x3 );
5 long x4 =333333333; printf ("% ld \ n " , x4 );
6 float x5 =0.45 f ; printf ("% f \ n " , x5 );
7 double x6 =455.54; printf ("% lf \ n " , x6 );
8 return 0;}
Parfois, il est nécessaire de procéder dans l’autre sens. Par exemple, on veut voir un int comme
un char. Dans ce cas, le programmeur doit effectuer une conversion explicite. En C, on parle
aussi d’opération de cast (ou coercition). Par exemple, on peut écrire :
1 int x =34444;
2 char y =( char )( x );
Le lecteur remarquera qu’il y a beaucoup plus d’entiers de type int (32 bits) que de
caractères de type char (8 bits). L’opération de cast a donc un caractère arbitraire et il faut
bien comprendre son effet. En général, les opérations de cast entre types produisent souvent
des erreurs et il faut les utiliser avec parcimonie.
28 Types atomiques
Chapitre 3
Contrôle
On peut voir le corps de chaque fonction comme une suite de commandes. Dans les pro-
grammes les plus simples on a une liste de commandes qu’on exécute une fois dans l’ordre.
On va présenter des opérateurs qui permettent d’exécuter les commandes selon un ordre plus
élaboré. Par exemple :
— On exécute une commande seulement si une certaine condition logique est satisfaite.
— On répète l’exécution d’une commande tant qu’une certaine condition logique est sa-
tisfaite.
— On arrête l’exécution d’une suite de commandes pour sauter directement à l’exécution
d’une commande plus éloignée.
(C1 ; C2 ); C3 ≡ C1 ; (C2 ; C3 )
29
30 Contrôle
3.2 Branchement
Un deuxième exemple d’opérateur de composition de commandes est le branchement. La
forme de base est :
if (b){C1 } else {C2 }
L’interprétation est qu’on évalue une condition logique b. Si elle est vraie on exécute C1 et
sinon C2 . Dans la syntaxe de C, on admet aussi une version sans branche else :
if (b){C1 }
3.3 Boucles
Une boucle permet d’exécuter une commande un nombre arbitraire de fois. L’opérateur
while est probablement le plus utilisé pour construire une boucle. Sa forme est :
while(b){C} .
La commande résultante évalue la condition logique b et elle termine si elle est fausse. Au-
trement, elle exécute la commande C et ensuite elle recommence à exécuter la commande
while(b){C}. Ainsi, d’un point de vue conceptuel on a l’équivalence suivante :
Boucle for
Pour améliorer la lisibilité du programme, on utilise aussi une boucle dérivée for avec la
forme :
for(C1 ; b; C2 ){C} (3.1)
En première approximation, la boucle for est équivalente à :
C1 ; while(b){C; C2 } . (3.2)
1. Bien sûr on pourrait aussi utiliser la fonction de bibliothèque sqrt pour résoudre ce problème.
Contrôle 33
Dans une bonne pratique de la boucle for, on utilise la commande C1 pour initialiser la boucle
et la commande C2 pour modifier les variables dont dépend la condition logique b (la condition
d’arrêt). Typiquement, il s’agit d’incrémenter ou décrémenter un indice et, en lisant le texte
du programme, il est aisé de déterminer combien de fois le corps C de la boucle for sera itéré.
Exemple 14 On souhaite lire un nombre naturel n et imprimer ses diviseurs propres (différents
de 1 et n). Pour résoudre ce problème, on peut utiliser une boucle for qui va parcourir les en-
tiers compris entre 2 et n/2.
1 int main (){
2 int n , i ;
3 /* lire n */
4 for ( i =2; i <= n /2; i = i +1){
5 if ( n % i ==0){
6 printf ("% d \ n " , i );}}
7 return 0;}
2. Si on se trouve dans une boucle for, continue va quand même exécuter la commande d’incre-
ment/décrement. Pour cette raison, la transformation de la boucle for en boucle while décrite dans (3.2)
doit être raffinée.
34 Contrôle
Remarque 5 Il faut utiliser break et continue seulement si on se trouve dans une boucle (ou
dans une commande switch qui sera discutée dans la section 3.5 qui suit).
Fonctions
Un programme C est composé d’une liste de fonctions qui peuvent s’appeler mutuellement.
Les fonctions sont un élément essentiel dans la modularisation et le test d’un programme.
Si une tâche doit être répétée plusieurs fois c’est une bonne pratique de lui associer une
fonction ; ceci permet de produire un code plus compact tout en clarifiant le fonctionnement
du programme.
Aussi si une tâche est trop compliquée il est probablement utile de la décomposer en
plusieurs fonctions. Le code de chaque fonction devrait tenir dans une page (20-30 lignes) et
avant de tester le programme dans son intégralité, il convient de s’assurer de la fiabilité de
chaque fonction.
La première ligne spécifie l’interface (ou en tête) de la fonction, à savoir le nom de la fonc-
tion (f), le type du résultat (t) et les types et les noms des arguments (t1 x1,. . .,tn xn).
Quand on appelle une fonction on définit les valeurs de ses arguments. Par exemple, dans
l’appel f(e1 , . . . , en ) on évalue les expressions e1 , . . . , en et on affecte leurs valeurs aux va-
riables x1, . . . , xn. On dit que l’appel d’une fonction est par valeur. Un appel de fonction est
une expression dont le type est le type du résultat de la fonction. Donc l’appel f(e1 , . . . , en )
a type t. Par ailleurs, le corps de la fonction contient des commandes return e où e est une
expression de type t.
35
36 Fonctions
1 int x =5;
2 int y =6;
3 void imprimer ( int x ){
4 printf ("% d \ n " , x );
5 printf ("% d \ n " , y );}
6 void portee ( int x ){
7 x = x +1;
8 imprimer ( x );
9 { int y =10;
10 imprimer ( y );}
11 imprimer ( y );
12 int y =20;
13 imprimer ( y );}
14 int main (){
15 int x = 4;
16 portee ( x );
17 imprimer ( x );
18 return 0;}
1 int x =5;
2 int y =6;
3 void imprimer ( int x ){
4 printf ("% d \ n " , x );}
5 void portee ( int x ){
6 x = x +1; imprimer ( x );
7 { int y =10; imprimer ( y );}
8 imprimer ( y );
9 int y =20; imprimer ( y );}
10 int main (){
11 int x = 4;
12 portee ( x );
13 imprimer ( x );
14 controle ();
15 return 0;}
16 void controle (){
17 if (x >0){
18 imprimer ( - - x );}
19 else {
20 imprimer ( x ++);};
21 int acc =1 , n =2;
22 int k ;
23 for ( k =1; k <= n ; k ++){
24 acc = k * acc ;
25 imprimer ( acc );};
26 int i =2;
27 while (i >0){
28 acc = acc - i ;i - -;
29 imprimer ( acc );
30 if ( i ==1){
31 break ;}
32 else {
33 continue ;}}
34 imprimer ( f (3));
35 return ;}
36 int f ( int x ){
37 switch ( x ){
38 case 0: return 1;
39 case 1: return 2;
40 default : return f (x -1)* f (x -2); } }
donc une interaction entre deux fonctions alors que lire une valeur/imprimer une valeur est
une interaction entre une fonction et l’utilisateur (ou un fichier externe au programme).
f (xi ) − 0
f 0 (xi ) =
xi − xi+1
dont on dérive :
f (xi )
xi+1 = xi − . (4.1)
f 0 (xi )
Le calcul sur les flottants étant approché, on fixe un niveau de précision souhaité et on
arrête l’itération dès que (cf. recherche dichotomique de l’exemple 13) :
Notons au passage qu’il faut étudier f et le point initial x0 pour s’assurer de la convergence
vers un zéro de la fonction. En effet, la méthode peut ne pas converger même si f est un
polynôme. Considérons une mise en oeuvre pour la fonction f (x) = x2 − a où a ≥ 0. Il
faut : (1) lire la valeur a, (2) itérer l’opération (4.1) jusqu’au niveau de précision souhaité et
(3) imprimer le résultat. Par ailleurs, il convient de prévoir une fonction C qui correspond
à la fonction mathématique f . Ainsi si l’on souhaite adapter le programme à une autre
fonction mathématique il suffira de modifier la fonction C correspondante. En suivant ces
considérations, une mise en oeuvre possible est la suivante.
1 # define epsilon 10 E -10
2 double a ;
3 double fun ( double x ){
4 return x * x - a ;}
5 double itere ( double x ){
6 double xnext ;
7 while (1){
8 xnext = x - fun ( x )/(2* x );
9 if ( fabs ( xnext - x ) < epsilon ){
10 return xnext ;}
11 else {
12 x = xnext ;}}}
13 int main (){
14 double x ;
15 // lire a
16 x = itere ( a );
17 // imprimer x
18 return 0;}
Fonctions 39
xi = a + ih 0≤i≤n.
h
Σi=0,...,n−1 Si = (2(Σi=1,...,n−1 f (xi )) + f (a) + f (b)) .
2
Il convient de dissocier le calcul de la somme des surfaces des trapèzes. Ainsi la structure
d’un programme pour ce problème pourrait être celle ci-dessous où l’on suppose que f est
la fonction de bibliothèque sinus. Comme pour la méthode de Newton-Raphson, on pourrait
ajouter des fonctions pour lire et imprimer et on pourrait encapsuler la fonction mathématique
dont on calcule l’intégrale dans une fonction C séparée.
1 double integral ( double a , double b , int n ){
2 double h =( b - a )/ n ;
3 double acc =0;
4 double x = a + h ;
5 int i ;
6 for ( i =1; i <= n -1; i ++){
7 acc = acc + sin ( x ); x = x + h ;}
8 acc = (2* acc + sin ( a )+ sin ( b ));
9 acc = ( acc * h )/2;
10 return ( acc );}
11 int main (){
12 double a ,b , val ; int n ;
13 /* lire a , b , n */
14 val = integral (a ,b , n );
15 /* imprimer résultat */
16 return 0;}
1. Lire un nombre de type int et imprimer sa représentation binaire (aussi de type int).
Par exemple :
On lit On imprime
19 10011
2. Lire un nombre de type int dont les chiffres varient dans {0, 1}, le voir comme un
nombre binaire et imprimer sa représentation décimale (aussi de type int). Par exemple :
On lit On imprime
10011 19
Le lecteur remarquera que dans cet exemple on utilise un sous-ensemble des valeurs
de type int (celles dont les chiffres varient sur 0 et 1) pour représenter les nombres
binaires.
Dans une mise en oeuvre il est naturel d’introduire une fonction pour chaque conver-
sion. Remarquons que la solution ci-dessous utilise la fonction de bibliothèque assert de la
bibliothèque assert.h. La commande assert(b) évalue la condition logique b. Si la condition est
fausse le calcul s’arrête et un message d’erreur est émis qui permet d’identifier l’assertion qui
n’est pas valide. Avec la fonction assert, on a une façon simple et fort utile de documenter et
tester un programme.
1 int dec_to_bin ( int d ){
2 int q , r ;
3 if ( d ==0){
4 return 0;}
5 else {
6 r = d %2;
7 q = d /2;
8 return ( r +10* dec_to_bin ( q ));}}
9 int bin_to_dec ( int b ){
10 int q , r ;
11 if ( b ==0){
12 return 0;}
13 else {
14 r = b %10;
15 q = b /10;
16 assert (( r ==0) || ( r ==1));
17 return ( r +2* bin_to_dec ( q ));}}
18 int main (){
19 printf (" Entrez 10 pour décimal et 2 pour binaire \ n ");
20 int choix ;
21 int x ;
22 scanf ("% d " ,& choix );
23 assert (( choix ==2)||( choix ==10));
24 if ( choix ==10){
25 printf (" Entrez un nombre décimale \ n ");}
26 else {
27 printf (" Entrez un nombre binaire \ n ");}
28 scanf ("% d " ,& x );
29 if ( choix ==10){
30 printf ("% d \ n " , dec_to_bin ( x ));}
31 else {
Fonctions 41
Fonctions récursives
Un programme C est composé d’une liste de fonctions qui peuvent s’appeler mutuellement.
En particulier, une fonction peut s’appeler elle même ; il s’agit alors d’un exemple de fonction
récursive (n fonctions qui s’appellent mutuellement sont aussi des fonctions récursives). On a
déjà examiné dans l’exemple 4 la programmation de l’algorithme d’Euclide par une fonction
récursive. Les fonctions récursives permettent de programmer aisément les définitions par
récurrence qu’on trouve souvent en mathématiques. Aussi, un certain nombre d’algorithmes
qui suivent une stratégie diviser pour régner se programment naturellement de façon récursive ;
par exemple, la recherche dichotomique et certains algorithmes de tri qu’on étudiera dans la
section 7.1 suivent cette stratégie. On a vu dans l’exemple 12 que l’algorithme d’Euclide peut
se programmer aussi avec une boucle ; on dira aussi de façon itérative. La récursion utilisée
dans la programmation de l’algorithme d’Euclide est de type terminal dans le sens qu’après
l’appel récursif il ne reste plus rien à faire et la fonction retourne immédiatement. 1 Il se trouve
que pour la récursion terminale (tail recursion en anglais), un compilateur optimisant peut
générer automatiquement un programme itératif équivalent.
Dans la section 5.1, on pratique la programmation récursive et itérative dans le cadre du
problème de l’évaluation de polynômes.
Comme on l’a vu dans la section 1.2, l’appel et le retour de fonction manipule implicitement
une pile de blocs d’activation. Il s’avère que dans certaines situations cette structure de données
permet une programmation élégante et compacte. Dans la section 5.2, on illustre une telle
situation avec le problème de la tour d’Hanoı̈.
Enfin, il y a aussi des situations dans lesquelles la programmation d’une définition par
récurrence à l’aide d’une fonction récursive peut générer un programme particulièrement
inefficace. Dans la section 5.3, on examine différentes stratégies pour contourner ce problème
dans le contexte du calcul de la suite de Fibonacci.
43
44 Fonctions récursives
x0 , x1 = x · x0 , x2 = x · x, x3 = x · x2 , . . .
Pour ce calcul, il faut donc effectuer 2 · n − 1 multiplications ainsi que n sommes. Cependant
le coût d’une somme est bien inférieur à celui d’une multiplication et donc on peut considérer
que le coût du calcul dépend essentiellement du nombre de multiplications.
Règle de Horner
La règle de Horner est un autre algorithme pour évaluer un polynôme de degré n dans un
point qui demande seulement n multiplications. On définit :
h0 = a n
hi = hi−1 x + an−i 1≤i≤n.
On remarque que :
Exemple 19 Pour mettre en oeuvre l’évaluation d’un polynôme on va faire l’hypothèse que le
programme lit le degré du polynôme, un point où il faut évaluer le polynôme et les coefficients
du polynôme. Pour l’instant, on ne dispose pas d’une structure de données pour mémoriser
(de façon simple) n + 1 coefficients où n est variable ; les tableaux qui seront discutés dans
le chapitre 6 feront l’affaire. Il s’agit donc de lire les coefficients et en même temps de faire
progresser l’évaluation du polynôme. 2 Pour mettre en oeuvre l’algorithme on a 4 choix pos-
sibles. En effet, on peut choisir entre la méthode d’évaluation directe et la méthode de Horner
et aussi entre une programmation par récursion et une par itération. On présente ci-dessous
la méthode de Horner programmée de façon récursive et itérative et on laisse en exercice le
même problème pour la méthode directe. Notez que dans la version récursive on lit les coef-
ficients dans l’ordre a0 , a1 , . . . , an alors que dans la version itérative on procède dans l’ordre
inverse.
1 double horner_rec ( int i , double x , int n ){
2 double a ;
3 printf (" Entrez coefficient % d \ n " , i );
4 scanf ("% lf " ,& a );
5 if ( i == n ){
6 return a ;}
7 else {
8 return ( a + x * horner_rec ( i +1 ,x , n ));}}
9 double horner_it ( double x , int n ){
10 double a ;
11 double b ;
12 printf (" Entrez coefficient % d \ n " , n );
2. On voit ici un exemple d’algorithme en ligne (on line en anglais) dans lequel le programme ne dispose pas
d’assez de mémoire ou de temps pour mémoriser toutes les entrées avant de commencer le calcul. Typiquement,
on trouve ce type d’algorithme dans des situations où il faut traiter des grandes masses de données et/ou le
processeur qui traite ces données a un pouvoir de calcul limité.
Fonctions récursives 45
où i, j, k sont 3 pivots distincts. Le raisonnement est le suivant : pour déplacer n disques du
pivot i au pivot j (i 6= j), on peut commencer par déplacer n − 1 disques du pivot i au pivot
k (k 6= i et k 6= j). Ensuite, on déplace le disque de diamètre maximal qui se trouve au pivot
i au pivot j et on termine en déplaçant n − 1 disques du pivot k au pivot j. Une possible mise
en oeuvre en C est la suivante :
1 void hanoi ( int n , int p1 , int p2 ){
2 int p3 = troisieme ( p1 , p2 );
3 if ( n ==1){
4 imprimer ( p1 , p2 );}
5 else {
6 hanoi (n -1 , p1 , p3 );
7 imprimer ( p1 , p2 );
8 hanoi (n -1 , p3 , p2 );}
9 return ;}
On laisse au lecteur le soin de programmer la fonction troisieme qui prend p1, p2 ∈ {0, 1, 2}
tels que p1 6= p2 et rend l’entier p ∈ {0, 1, 2} différent de p1 et p2. On notera que chaque appel
à la fonction hanoi avec n > 1 génère deux appels à la même fonction. En particulier, quand on
commence à calculer hanoi(n − 1, . . .) on utilise la pile des blocs d’activations pour se souvenir
qu’il reste encore à exécuter imprimer(. . .) ainsi qu’un deuxième appel hanoi(n − 1, . . .). Le
lecteur est invité à modifier le code ci-dessus pour qu’il trace chaque appel à la fonction hanoi
en imprimant un petit message.
46 Fonctions récursives
Il y a des mathématiques non-triviales autour de cette suite. . . mais ici on s’y intéresse parce
que elle illustre un problème de mise en oeuvre qu’on rencontre parfois dans les définitions
récursives. Une mise en oeuvre directe de la fonction f pourrait être la suivante.
1 int fibo_rec ( int n ){
2 switch ( n ){
3 case 0: return 0 ;
4 case 1: return 1;
5 default : return fibo_rec (n -2)+ fibo_rec (n -1);}}
Cette solution est particulièrement inefficace car on recalcule plusieurs fois la fonction f
sur les mêmes arguments. Dans le cas de la suite de Fibonacci, il est facile de concevoir une
version itérative dans laquelle on calcule f (0), f (1), f (2), . . . exactement une fois et au pas
i ≥ 2 on se souvient de la valeur de la fonction f dans i − 1 et i − 2.
1 int fibo_it ( int n ){
2 int x =0;
3 int y =1;
4 switch ( n ){
5 case 0: return 0;
6 case 1: return 1;
7 default : {
8 int z =0;
9 int i ;
10 for ( i =2; i <= n ; i ++){
11 z=x+y;
12 x=y;
13 y = z ;}
14 return ( z );}}}
Il est intéressant de noter qu’on peut mettre en oeuvre cette même idée en utilisant une
fonction récursive un peu plus générale (fonction fibo aux).
1 int fibo_aux ( int n , int x , int y , int i ){
2 if ( i == n ){
3 return ( y );}
4 else {
5 return fibo_aux (n ,y , x +y , i +1);}}
6 int fibo_rec_eff ( int n ){
7 switch ( n ){
8 case 0: return 0;
9 case 1: return 1;
10 default : return fibo_aux (n ,0 ,1 ,1);}}
Il existe aussi une technique générale dite de mémoı̈sation qui permet de transformer
automatiquement une fonction récursive. On mentionne l’idée générale sans aller dans les
détails car on ne dispose pas encore des structures de données nécessaires. On associe à la
fonction une structure de données dans laquelle on mémoı̈se tous les arguments passés à
Fonctions récursives 47
la fonction ainsi que les valeurs retournées. Chaque fois qu’on appelle la fonction avec un
argument on vérifie d’abord dans la structure si la fonction a été déjà appelée avec le même
argument et dans ce cas on retourne directement le résultat. Autrement, on effectue le calcul
et on mémorise le résultat dans la structure.
48 Fonctions récursives
Chapitre 6
Tableaux
T x[n]; (6.1)
Ceci a l’effet de réserver un segment de mémoire suffisant pour contenir n données de type T
et d’associer l’adresse de base du segment au nom x. On notera que le nom d’un tableau est
associé à une adresse et non pas à son contenu.
Dans une déclaration comme (6.1), le contenu du tableau x n’est pas défini. Comme pour
les variables de type primitif, il est une erreur de lire un tableau avant de l’avoir défini. En
C, il est aussi possible de déclarer un tableau et en même temps de l’initialiser. Par exemple,
déclare un tableau a avec 10 cellules et initialise la i-ème cellule avec la valeur i pour i =
0, 1, . . . , 9.
On peut écrire ou lire le i-ème élément du tableau en utilisant la notation x[i] à condition
que 0 ≤ i ≤ (n − 1). C’est au programmeur de respecter les bornes. En particulier, on notera
qu’on commence à compter de 0 et que donc un accès à x[n] produit une erreur.
Exemple 20 On met en garde le lecteur sur le fait que ce type d’erreur peut passer inaperçu
et provoquer un comportement bizarre du programme. Par exemple, avec le compilateur gcc
le programme ci-dessous compile et imprime 10.
1 int main (){
2 int a [4];
3 int b [4];
4 b [0]=10;
5 printf ("% d \ n " , a [4]);
6 return 0;}
49
50 Tableaux
Exemple 21 Voici un programme qui lit le degré et les coefficients d’un polynôme et évalue
le polynôme dans un point. Par opposition au programme considéré dans la section 5.1, on
utilise maintenant un tableau pour mémoriser tous les coefficients. Il est donc possible de lire
complètement les données en entrée avant d’évaluer le polynôme. On laisse au lecteur le soin
de modifier le programme ci-dessous de façon à utiliser la règle de Horner.
1 int main (){
2 int n ;
3 double x ;
4 printf (" Entrez degré polyn^ o me \ n ");
5 scanf ("% d " ,& n );
6 printf (" Entrez point \ n ");
7 scanf ("% lf " ,& x );
8 double a [ n +1];
9 int i ;
10 for ( i =0; i <= n ; i ++){
11 printf (" Entrez coefficient % d \ n " , i );
12 scanf ("% lf " ,& a [ i ]);}
13 double s = a [0];
14 double y =1;
15 for ( i =1; i <= n ; i ++){
16 y=x*y;
17 s = a [ i ]* y + s ;}
18 printf (" La valeur du polyn^ o me est : % lf \ n " , s );
19 return 0;}
Exemple 22 Considérons un petit exemple qui permet de comparer les variables de type
tableau aux variables de type primitif. Dans (7), on appelle f en lui passant l’adresse du
tableau a et la valeur de la variable x de la fonction main. Dans (2), la fonction f incrémente
la variable x de f et l’élément a[0] du tableau a de la fonction main. Dans (8), on imprime 0
car la variable x du main n’a pas été modifiée alors que dans (9) on imprime 1.
1 void f ( int a [] , int x ){
2 x = x +1;
3 a [0]= a [0]+1;}
4 int main (){
5 int x =0;
Tableaux 51
Remarque 6 On peut se demander pourquoi on passe les variables de type primitif par valeur
et les variables de type tableau par adresse. La raison est que les tableaux peuvent occuper
beaucoup de mémoire et qu’autant que possible il est préférable de les partager plutôt que de
les dupliquer. En cas de nécessité, il n’est pas compliqué de dupliquer un tableau : il suffit
de déclarer un tableau dans la fonction appelée et d’y recopier le tableau dont la fonction
appelante a fournit l’adresse. Dans le cas de la fonction f de l’exemple 22 ci-dessus, on aura
par exemple :
1 void f ( int a [] , int x ){
2 x = x +1;
3 int b [10];
4 int i ;
5 for ( i =0; i <10; i ++){
6 b [ i ]= a [ i ];}
7 b [0]= b [0]+1;}
Exemple 23 On souhaite écrire une fonction qui prend en argument un tableau et sa taille
et imprime le minimum et le maximum du tableau. Une solution possible est la suivante.
1 void minmax ( int a [] , int n ){
2 int min , max , i ;
3 min = a [0];
4 max = a [0];
5 for ( i =1; i < n ; i ++){
6 if ( a [ i ] < min ){
7 min = a [ i ];}
8 else {
9 if ( max < a [ i ]){
10 max = a [ i ];}}}
11 printf (" min =% d \ n " , min );
12 printf (" max =% d \ n " , max );
13 return ;}
En suivant la discussion ci-dessus, on notera que la fonction minmax utilise le tableau
dont l’adresse lui est communiquée par la fonction appelante.
Dans le pire des cas, la fonction minmax effectue 2 · (n − 1) comparaison (trouvez un tel
cas !). Il est possible d’utiliser un autre algorithme qui lit les éléments du tableau par couple
et les compare au min et au max. Vérifiez qu’on peut effectuer cette opération avec au plus 3
comparaisons et dérivez un algorithme qui effectue 23 n comparaisons dans le pire des cas.
La liste des premiers inférieurs à un n donné peut être générée avec un algorithme ancien
connu comme crible d’Ératosthène :
pour i = 2, . . . , n
P [i] = true
√
pour i = 2, . . . , b nc
pour j = i, . . . , n/i
P [i · j] = false
Exemple 25 Voici une fonction filtre qui prend en entrée un tableau de short de n + 1
éléments et marque avec 1 les nombres premiers et avec 0 les autres.
1 void filtre ( short f [] , int n ){
2 int i , j ;
3 int r =( int )( sqrt ( n ));
4 for ( i =2; i <= n ; i ++){
5 f [ i ]=1;}
6 for ( i =2; i <= r ; i ++){
7 for ( j = i ; j <= n / i ; j ++){
8 f [ i * j ]=0;}}}
On a donc une méthode pour générer un segment initial des nombres premiers. Considérons
maintenant le problème de savoir si un nombre n est premier. Par exemple, prenons n = 15413.
On calcule, √
b 15413c = 124 .
Avec le crible d’Ératosthène, on peut calculer les premiers inférieurs à 124 :
2 3 5 7 11 13 17 19 23
29 31 37 41 43 47 53 59 61 67
71 73 79 83 97 101 103 107 109 113
n = 2 · 2 · 2 · 5 · 5 · 7 · 11 .
On a donc un algorithme pour factoriser un nombre. Voici une mise en oeuvre possible de
l’algorithme de factorisation où l’on suppose que la fonction imprimer factorisation reçoit en
argument un tableau de short premier où les nombres premiers ont été marqués en utilisant
la fonction filtre du crible d’Ératosthène. Le tableau premier est initialisé une fois et réutilisé
dans tous les appels de la fonction imprimer factorisation. 1
1 void i m p r i m e r _ f a c t o r i s a t i o n ( short premier [] , int n ){
2 int m = ( int )( sqrt ( n ));
3 int i ;
4 for ( i =2; i <= m ; i ++){
5 if ( premier [ i ] && ( n % i ==0)){
6 printf ("% d " , i );
7 break ;}}
8 if (i > m ){
9 printf ("% d " , n );
10 return ;}
11 else {
12 i m p r i m e r _ f a c t o r i s a t i o n ( premier , n / i );}}
1. On peut optimiser le calcul en se souvenant du dernier nombre premier essayé et/ou en construisant un
√
tableau qui contient les nombres premiers compris entre 2 et b nc.
54 Tableaux
On notera que cette fonction contient 3 boucles for imbriquées et qu’elle effectue de l’ordre
de n3 multiplications.
Exemple 26 On souhaite imprimer les éléments d’un tableau de tableaux a de type int a[m][n]
avec la contrainte que l’élément a[i][j] doit être imprimé avant l’élément a[k][l] si i + j < k + l.
Par exemple, si a est comme suit :
4 5 7 3
3 1 9 10
8 2 1 4
8, 2, 3, 1, 1, 4, 4, 9, 5, 10, 7, 3 .
Tri et permutations
Le tri d’une suite finie d’éléments selon un certain ordre est une opération fondamentale.
En faisant l’hypothèse que la suite est représentée par un tableau, on présente et on analyse
l’efficacité de 3 algorithmes de tri. Une permutation sur un ensemble fini admet aussi une
représentation naturelle en tant que tableau. Dans ce contexte, on étudie la composition,
l’inversion, la génération aléatoire et l’énumération de permutations.
Tri à bulles
On peut écrire une fonction bulles(i) qui compare les i − 1 couples aux positions :
et les permute si elles ne sont pas en ordre croissant. Le coût est linéaire en i − 1. A la fin de
l’exécution de bulles(i) on est sûr que l’élément le plus grand se trouve à la position i. Pour
trier, il suffit donc d’exécuter :
55
56 Tri et permutations
Bornes inférieures
Quelle est le coût minimal d’un algorithme de tri ? Il est clair que tout algorithme de tri
doit examiner l’intégralité de son entrée et que le coût de cette opération est linéaire.
Les algorithmes de tri qu’on a considéré sont basés sur la comparaison d’éléments. Pour ce
type d’algorithmes un simple argument combinatoire qui va suivre permet de conclure qu’on
ne peut pas faire mieux que n log n.
On considère un algorithme (déterministe) qui prend en entrée un tableau de n éléments
x0 , . . . , xn−1 . L’algorithme compare un nombre fini de fois et deux par deux les éléments du
tableau. A la fin de cette phase de comparaison, l’algorithme n’a plus accès au tableau et
il calcule une permutation π sur {0, . . . , n − 1} telle que xπ(0) ≤ · · · ≤ xπ(n−1) . Combien de
comparaisons faut-il faire dans le pire des cas ? Le calcul de l’algorithme peut être visualisé
comme un arbre binaire enraciné où on associe une comparaison à chaque noeud interne et
une permutation à chaque feuille. L’arbre binaire doit avoir au moins une feuille pour chaque
permutation sur {0, . . . , n − 1}, soit n! feuilles ; sinon, il est facile de voir qu’il y a une entrée
sur laquelle l’algorithme n’est pas correct. Le pire des cas correspond au chemin le plus long
de la racine à une feuille. La longueur (on compte le nombre d’arêtes qu’il faut traverser) de
ce chemin est la hauteur de l’arbre. Il est aisé de vérifier qu’un arbre binaire de hauteur h
peut avoir au plus 2h feuilles. On doit donc avoir 2h ≥ n!, soit :
On remarque que : Z n
Σi=1,...,n log2 i ≥ log2 xdx ,
1
et en calculant l’intégrale on trouve une valeur de l’ordre de n log n.
L’argument présenté fait des hypothèses restrictives sur la forme de l’algorithme. Voici
un exemple d’algorithme qui ne respecte pas ses restrictions et qui a une coût linéaire si le
nombre d’éléments différents qui peuvent apparaı̂tre dans la séquence x0 , . . . , xn−1 est linéaire
en n. Pour fixer les idées, on suppose que la séquence est mémorisée dans le tableau T et que
0 ≤ T [i] ≤ 10 · n, pour i = 0, . . . , n − 1. Dans ce cas, on peut en temps linéaire en n :
— allouer un tableau C avec 10 · n entiers initialisés à 0,
— parcourir le tableau T et pour chaque éléments T [i] incrémenter C[T [i]] et
— parcourir le tableau C et pour chaque élément C[i] écrire C[i] fois i dans le tableau T .
7.3 Permutations
Une permutation sur l’ensemble {0, . . . , n−1} est une fonction bijective p : {0, . . . , n−1} →
{0, . . . , n − 1}. On représente une telle permutation par un tableau d’entiers de taille n qui
contient les entiers {0, . . . , n − 1} exactement une fois. Soit id la permutation identité et ◦ la
composition de permutations. L’ensemble des permutations sur l’ensemble {0, . . . , n − 1} est
un groupe commutatif. En particulier, chaque permutation admet une permutation inverse par
rapport à la composition. Un point fixe d’une permutation p est un élément i ∈ {0, . . . , n − 1}
tel que p(i) = i.
Exercice 4 Programmez une fonction C d’en tête void comp(int n, int r[n], int p[n], int q[n])
qui prend en entrée deux permutations (représentées par les tableaux p et q) et écrit dans le
premier tableau r la représentation de la permutation composition ‘p ◦ q 0 .
60 Tri et permutations
Exercice 5 Programmez une fonction C d’en tête void inv(int n, int p[n], int q[n]) qui prend en
entrée une permutation (représentée par le tableau p) et écrit dans le tableau q la représentation
de la permutation inverse de p.
Exercice 6 Programmez une fonction C d’en tête int nbpointfixe(int n, int p[n]) qui prend en
entrée une permutation (représentée par le tableau p) et retourne le nombre de points fixes
de p.
0 1 2
0 2 1
1 0 2
1 2 0
2 0 1
2 1 0
Pour préparer le terrain on peut d’abord considérer le problème suivant : énumérer toutes
les fonctions sur {0, . . . , n − 1}. Une fonction sur {0, . . . , n − 1} peut être représentée par un
tableau avec n éléments qui varient sur {0, . . . , n − 1}. Énumérer les fonctions revient alors
à énumérer de tels tableaux. On peut suivre l’algorithme suivant : au pas i on écrit dans la
cellule i du tableau la valeur j pour j = 0, . . . , n − 1. Pour chaque j, on vérifie si i = n − 1.
Si c’est le cas on imprime le tableau et sinon on incrémente i et on recommence. Voici un
codage (à compléter) de cet algorithme en C.
1 void fonct ( int g [] , int i , int n ){
2 int j ;
3 for ( j =0; j < n ; j ++){
4 g [ i ]= j ;
5 if ( i ==( n -1)){
6 /* imprimer fonction */ }
7 else {
8 fonct (g , i +1 , n );}}}
9 int main (){
10 int n ;
11 /* lire n */
12 int g [ n ];
13 fonct (g ,0 , n );
14 return 0;}
Exercice 7 Programmez une fonction C qui vérifie si une fonction sur {0, . . . , n − 1} est une
permutation. Modifiez le programme ci-dessus pour qu’il imprime seulement les permutations.
Le programme pour énumérer les permutations dérivé de l’exercice 7 n’est pas parti-
culièrement efficace car il énumère toutes les fonctions sur {0, . . . , n−1} pour ensuite imprimer
seulement les permutations. En général, on aura nn fonctions et seulement n! permutations.
Par exemple, pour n = 7, on a 77 = 823543 >> 5040 = 7!.
Tri et permutations 61
Une approche plus efficace consiste donc à détecter aussi tôt que possible les fonctions
partiellement spécifiées qui n’ont aucune chance de devenir des permutations. Une condition
nécessaire et suffisante pour qu’une fonction partiellement spécifiée sur {0, . . . , n − 1} puisse
devenir une permutation est qu’il n’y ait pas un élément répété dans l’image de la fonction
(pour construire une permutation il faut utiliser les éléments dans {0, . . . , n − 1} exactement
une fois). On va donc introduire un deuxième tableau dans lequel on va se souvenir des
éléments déjà utilisés dans la construction d’une permutation. Une programmation possible
est la suivante.
1 void perm ( int p [] , short f [] , int i , int n ){
2 int j ;
3 for ( j =0; j < n ; j ++){
4 if ( f [ j ]){
5 f [ j ]=0; /* j n ’ est plus disponible */
6 p [ i ]= j ;
7 if ( i ==( n -1)){
8 /* imprimer permutation */}
9 else {
10 perm (p ,f , i +1 , n );}
11 f [ j ]=1; /* j à nouveau disponible */ }}}
12 int main (){
13 int n ;
14 /* lire n */
15 int g [ n ];
16 short f [ n ];
17 int j ;
18 for ( j =0; j < n ; j ++){
19 f [ j ]=1;} /* tous j disponibles */
20 perm (p ,f ,0 , n );
21 return 0;}
Encore une autre possibilité est de supposer qu’au début le tableau contient une per-
mutation. Ensuite on fait varier un indice i de 0 à n − 1, un indice j entre i et n − 1. Pour
chaque couple (i, j), on échange l’élément d’indice i avec celui d’indice j, on appelle la fonction
d’énumération et on échange encore.
1 void perm ( int t [] , int n , int i ){
2 if ( i ==( n -1)){
3 /* imprimer t */}
4 else {
5 int j ;
6 for ( j = i ;j < n ; j ++){
7 /* échanger t [ i ] et t [ j ] */
8 perm (t ,n , i +1);
9 /* échanger t [ i ] et t [ j ] */}}}
Le langage C comporte un certain nombre de types primitifs. Aussi les tableaux et les
pointeurs nous permettent de créer des nouveaux types. Par exemple, avec int a[] ; on déclare
a comme un tableau d’entiers. Dans ce chapitre, on va introduire des nouvelles façons de
construire des types.
8.1 Structures
Souvent on a besoin d’agréger des données de types différents. Par exemple, le nom (string)
et l’âge (int) d’un patient. On pourrait définir un nouveau type produit :
Un élément de type fiche serait donc un couple. En utilisant les projections on pourrait
accéder au premier et deuxième composant. En programmation, on préfère donner des noms
mnémoniques aux projections. On parle alors de structures (en C) ou d’enregistrements (re-
cords en anglais) dans d’autres langages.
La déclaration du type fiche en C pourrait prendre la forme suivante où l’on suppose que
10 caractères suffisent pour représenter un nom :
1 struct fiche { char nom [10]; int age ;};
Si x est une valeur de type struct fiche on peut accéder au premier composant avec x.nom
et au deuxième avec x.age. On insiste sur le fait que le nom du type est struct fiche et non
pas fiche. Cependant, il est possible d’utiliser le nom fiche en posant :
1 typedef struct fiche fiche ;
La valeur d’une variable de type fiche est son contenu et non pas l’adresse de mémoire où
ce contenu est mémorisé. De ce point de vue, les variables de type structure se comportent
comme les variables de type primitif (int, float,. . .) et non pas comme les variables de type
tableau. Si l’on souhaite utiliser une fonction pour modifier une structure on doit soit passer
l’adresse de la structure (comme dans (1)) soit recevoir une copie modifiée de la structure
(comme dans (2)). Si on procède comme dans (3), la structure de la fonction appelante n’est
pas modifiée. Ainsi, dans (4) le nom imprimé sera georges.
1 fiche f ( fiche p ){
2 strcpy ( p . nom ," frank ");
63
64 Structures et Unions
3 return p ;}
4 void g ( fiche * p ){
5 strcpy ((* p ). nom ," georges ");}
6 void main (){
7 fiche p ;
8 strcpy ( p . nom ," marius ");
9 p . age =27;
10 p = f ( p ); \\(1)
11 g (& p ); \\(2)
12 f ( p ); \\(3)
13 printf (" nom =% s , age =% d \ n " ,( p . nom ) ,( p . age ));} \\(4)
8.2 Rationnels
Dans cet exemple on illustre l’utilisation du type structure. On utilise le type :
1 struct rat { int num ; int den ;};
2 typedef struct rat rat ;
pour représenter les nombres rationnels avec les conditions suivantes : (1) le champ num
représente le numérateur et le champ den le dénominateur, (2) le champ num est un entier et
le champ den est toujours un entier positif et (3) si le champ num est différent de 0 alors le
plus grand commun diviseur des champs num et den est 1. Dans la suite un rationnel est une
valeur de type struct rat qui respecte ces conditions. En particulier, une fonction qui prend en
argument un rationnel n’a pas à vérifier ces conditions et une fonction qui rend comme résultat
un rationnel doit assurer ces conditions. L’intérêt de cette représentation des rationnels par
rapport à celle usuelle qui utilise les flottants est qu’en l’absence de débordements les 4
opérations arithmétiques peuvent être calculées de façon exacte (sans approximations).
Exercice 8 Programmez une fonction d’en tête void imp rat(rat r) qui prend en argument un
rationnel et l’imprime (d’une façon agréable à lire) sur la sortie standard. Ensuite, program-
mez les fonctions suivantes qui déterminent si un rationnel est égal à un autre rationnel et si
un rationnel est plus petit ou égal qu’un autre rationnel.
Il est utile d’avoir une fonction build qui prend deux nombres entiers n et d avec d 6= 0
et rend comme résultat un rationnel qui correspond à nd . Une façon élégante de résoudre ce
problème est d’utiliser la fonction pgcd considérée dans l’exemple 4. Dans la suite on suppose
aussi que abs(n) est la valeur absolue de l’entier n.
1 rat build ( int n , int d ){
2 assert ( d !=0);
3 rat r ;
4 if ( n ==0){
5 r . num =0;
6 r . den =1;
7 return r ;}
8 int div = pgcd ( abs ( n ) , abs ( d ));
9 if (d <0){
Structures et Unions 65
10 n=-n;
11 d = - d ;}
12 if ( div >1){
13 n = n / div ;
14 d = d / div ;}
15 r . num = n ;
16 r . den = d ;
17 return r ;}
Les champs q1 et q2 correspondent aux points qui déterminent les deux extrémités du
segment. Ces extrémités font partie du segment et on peut avoir des segments dégénérés où
les deux extrémités coı̈ncident.
On commence par programmer une fonction allign qui prend en argument 3 points et
retourne 1 s’ils sont alignés (il y a une droite qui passe par les 3 points) et 0 autrement. La
fonction utilise une fonction eqp pour vérifier l’égalité de deux points et elle distingue 3 cas.
Dans (1), au moins deux points sont égaux et donc les 3 points sont alignés. Dans (2), p1
et p2 ont la même abscisse et donc les points sont alignes si et seulement si p3 a la même
66 Structures et Unions
abscisse que p1. Dans (3), on sait que les 3 points sont différents et p1 et p2 n’ont pas la
même abscisse. On peut donc calculer la droite qui passe par p1 et p2 et vérifier si p3 est sur
la droite.
1 short allign ( point p1 , point p2 , point p3 ){
2 if ( eqp ( p1 , p2 ) || eqp ( p1 , p3 ) || eqp ( p2 , p3 )){ //(1)
3 return 1;}
4 if ( eq ( p1 .x , p2 . x )){ //(2)
5 return eq ( p1 .x , p3 . x );}
6 rat a = mul ( sum ( p2 .y , op ( p1 . y )) , inv ( sum ( p2 .x , op ( p1 . x )))); //(3)
7 rat b = sum ( p1 .y , op ( mul (a , p1 . x )));
8 return eq ( p3 .y , sum ( mul (a , p3 . x ) , b ));}
Ensuite, on programme une fonction dist qui prend en argument deux points et calcule
leur distance Euclidienne exprimée en tant que valeur de type float.
1 float dist ( point p1 , point p2 ){
2 rat ry = sum ( p2 .y , op ( p1 . y ));
3 rat rx = sum ( p2 .x , op ( p1 . x ));
4 rat r = sum ( mul ( ry , ry ) , mul ( rx , rx ));
5 float f = ( float )( r . num )/( float )( r . den );
6 return sqrt ( f );}
On illustre la combinaison de tableaux et de structures en programmant une fonction min-
dist qui prend en argument un tableau de n points (n ≥ 2) et retourne la distance Euclidienne
minimale entre deux points du tableau.
1 float mindist ( int n , point t [ n ]){
2 assert (n >=2);
3 float min = dist ( t [0] , t [1]);
4 int i , j ;
5 for ( i =0; i < n ; i ++){
6 for ( j = i +1; j < n ; j ++){
7 float d = dist ( t [ i ] , t [ j ]);
8 if (d < min ){
9 min = d ;}}}
10 return min ;}
Pour un autre exemple de combinaison de tableaux et de structures on programme le
calcul du barycentre de n points p1 , . . . , pn avec la même masse. On rappelle que dans ce cas
le barycentre est égal à la somme (vectorielle) des points multipliée par le scalaire n1 .
1 point barycentre ( int n , point t [ n ]){
2 point s ;
3 s . x = build (0 ,1); s . y = build (0 ,1);
4 int i ;
5 for ( i =0; i < n ; i ++){
6 s . x = sum ( t [ i ]. x , s . x );
7 s . y = sum ( t [ i ]. y , s . y );}
8 rat r = build (1 , n );
9 s . x = mul ( s .x , r );
10 s . y = mul ( s .y , r );
11 return s ;}
Enfin on programme une fonction app qui prend en argument un segment et un point et
rend 1 si le point est sur le segment (extrémités comprises) et 0 autrement. Pour résoudre ce
Structures et Unions 67
8.4 Unions
Parfois on souhaite disposer d’une variable qui peut prendre une valeur de types différents.
Par exemple, le nom ou l’âge d’un patient. On pourrait définir un nouveau type union (dis-
jointe) :
fiche = string + int .
Un élément de type fiche serait alors soit un string soit un int. A nouveau, on préfère donner
des noms mnémoniques. Voici une déclaration possible du type fiche en C :
1 union fiche { char nom [10] ; int age ;} ;
Comme pour les structures, si x a type union fiche on accède à sa valeur en écrivant x.nom
ou x.age. Aussi la valeur d’une variable de type union est son contenu (pas son adresse).
Du point de vue de l’utilisation de la mémoire, il peut être intéressant d’utiliser un type
union au lieu d’un type structure. Par exemple, pour mémoriser une valeur de type union fiche
il faut 10 octets alors que pour mémoriser une valeur de type struct fiche il faut 14 = 10 + 4
octets.
Dans le langage C, les types unions ne protègent pas le programmeur de certains erreurs.
En effet, rien nous empêche d’écrire :
1 union fiche x ;
2 ( x . nom )[0]= ‘ b ’;
3 x . age = x . age +1;
En général, le compilateur ne sait pas prévoir si une variable de type union fiche contiendra
un tableau de caractères ou un entier. En principe, il est possible de : (i) intégrer dans une
valeur de type union une information qui nous permet de déduire son type et (ii) vérifier
au moment de l’exécution la cohérence des opérations qu’on effectue sur des valeurs de type
union.
Exemple 27 On suppose qu’une figure est soit un cercle soit un triangle qu’on représente
avec les types suivants.
68 Structures et Unions
Pointeurs
int ∗ p .
int a[]
et pour déclarer une fonction f qui prend en argument un pointeur à un entier et retourne un
pointeur à un entier on écrit :
int ∗ f(int ∗ p){...}
On va maintenant considérer différentes utilisation des pointeurs de variables.
Exemple 28 Dans 1, p est un pointeur d’entier qui reçoit l’adresse de x dans 2. Dans 3, le
contenu de l’adresse p (donc la valeur de x) est affecté à y et donc dans 4 on imprime 1. Dans
5, p prend l’adresse de z[0] et donc dans 6, on affecte à z[0] la valeur 1 qu’on imprime dans
7.
69
70 Pointeurs
1 main (){
2 int x =1 , y =2 , z [10] , * p ;
3 p =& x ;
4 y =* p ;
5 printf (" y =% d \ n " , y );
6 p =& z [0];
7 * p =1;
8 printf (" z [0]=% d \ n " ,* p );}
Exemple 29 La fonction f déclare x et y comme des pointeurs d’entiers. Ainsi, f est capable
de permuter le contenu des variables a et b de la fonction appelante main.
1 void f ( int *x , int * y ){
2 int aux ;
3 aux =* x ;
4 * x =* y ;
5 * y = aux ;}
6 main (){
7 int a =1 , b =2;
8 f (& a ,& b );
9 printf (" a % d \ n " , a );
10 printf (" b % d \ n " , b );}
Ainsi si b et h sont des pointeurs alors l’expression b + (h − b)/2 dénote un pointeur alors
que l’expression (b + h)/2 est refusée par le compilateur.
Exemple 31 On programme une fonction qui effectue une recherche dichotomique d’un en-
tier x sur un segment de mémoire censé contenir une suite croissante de n entiers à partir de
l’adresse t.
1 int dicho ( int *t , int n , int x ){
2 int * b = t ;
3 int * h = t +( n -1);
4 while (1){
5 int * m = b +( h - b )/2;
6 if (* m == x ){
7 return 1;}
8 if ((* m < x )&&( m != h )){
9 b = m +1;
10 continue ;}
11 if ((* m > x )&&( m != b )){
12 h =m -1;
13 continue ;}
14 return 0;}}
Exemple 32 Dans 4 on utilise la directive %s pour lire une chaı̂ne de caractères. La chaı̂ne ne
doit pas dépasser 10 caractères (en comptant aussi le symbole spécial \0). Ce programme utilise
deux fonctions de la bibliothèque string.h, à savoir strcpy (copie) et strcat (concaténation).
Dans 7, on copie i dans o et dans 8 on concatène middle à o. De même, dans 9 on concatène
i à o. Enfin, dans 10 on imprime o avec la directive %s. Ce type de programmation est fragile
à cause des débordements possibles des tableaux de caractères qui ne sont pas remarqués par
le compilateur. Ainsi, il est possible que dans 10 on imprime aussi le contenu du tableau b
qui à priori n’a rien à voir avec le contenu du tableau o.
1 main (){
2 char i [10];
3 printf (" Entrez une chaine \ n ");
4 scanf ("% s " , i );
5 char o [20];
72 Pointeurs
Exercice 9 Programmez une fonction qui prend en argument un mot (représenté par une
pointeur de caractères) et le remplace par le mot inverse. Par exemple, abacus est remplacé
par sucaba. Programmez une fonction qui prend en argument deux mots et retourne 1 si le
premier précède le deuxième dans l’ordre de l’annuaire téléphonique et 0 autrement.
Exemple 33 Voici une fonction intmap qui attend en argument un pointeur de fonction f de
int vers int et un tableau de n entiers et applique la fonction f à chaque élément du tableau.
1 void intmap ( int (* f )( int ) , int n , int t [ n ]){
2 int i ;
3 for ( i =0; i < n ; i ++){
4 t [ i ]=(* f )( t [ i ]);}}
On peut appeller la fonction intmap en lui passant en argument, par exemple, une fois une
fonction pour éléver au carré et une autre fois une fonction pour éléver au cube comme dans
le code suivant.
1 int square ( int x ){
2 return x * x ;}
3 int cube ( int x ){
4 return x * x * x ;}
5 void main (){
6 int t [5] = {4 ,4 ,1 ,0 ,3};
7 intmap ( square ,5 , t );
8 (...)
9 intmap ( cube ,5 , t );
10 (...)}
Dans 2, la fonction void inc prétend que p pointe vers un entier mais dans 8 la fonction
main lui passe en argument un pointeur vers un caractère. Ainsi, avec mon compilateur le pro-
gramme imprime 2 et 75780194 ! Cet exemple montre qu’en faisant des cast, le programmeur
peut introduire des erreurs de typage qui ne seront pas détectés par le compilateur.
Exemple 35 Avec les reserves évoquées ci-dessus, voici une façon de programmer et d’utili-
ser une fonction map générique.
1 void mapgen ( void * tab , int n , size_t t , void (* f )( void *)){ //(1)
2 int i ;
74 Pointeurs
Dans (1), la fonction mapgen attend un pointeur (vers un tableau) tab, le nombre d’éléments
n du tableau, leur taille (en octets) t et un pointeur de fonction f qui attend un pointeur et ne
retourne pas de résultat (la fonction f agit donc par effet de bord). Pour utiliser la fonction
mapgen pour éléver au carré un tableau d’entiers, on commence par déclarer dans (2) une
fonction square du type attendu par mapgen. La fonction convertit explicitement un pointeur
vers void en pointeur vers int et ensuite élève au carré son contenu. Notez que dans (3) le
compilateur ne fait pratiquement aucune vérification ; c’est l’utilisateur qui doit assurer la
cohérence des arguments fournis à mapgen.
Exemple 36 On aimerait écrire une fonction de tri qui prend en argument un tableau d’éléments
de type T et un prédicat de comparaison cmp : T × T → Bool et qui trie le tableau par ordre
croissant d’après l’ordre défini par le prédicat. Il s’agit donc de combiner la notion de poin-
teur de fonction avec celle de fonction générique. On illustre l’approche dans le cadre de la
fonction de tri qsort qui se trouve dans la bibliothèque stdlib.h. Le type de la fonction qsort
est le suivant :
1 void qsort ( void * base , size_t n , size_t size ,
2 int (* cmp )( const void * , const void *))
— base pointe à un tableau (du moins on l’espère car son type est pointeur vers void).
— size t est un type prédéfini d’entiers non-signés. La fonction sizeof(T) donne la taille
(en octets) d’une valeur de type T.
— n est la taille du tableau.
— size est la taille d’un élément du tableau.
— const indique que l’argument n’est pas modifié (est constant).
— cmp prend deux arguments et rend une valeur négative, 0 ou positive si le premier est
plus petit, égal ou plus grand que le second.
Il est possible d’appliquer la fonction de tri qsort à des tableaux de type différent et avec des
prédicats de comparaison différents. Par exemple, pour trier de façon croissante un tableau
d’entiers avec l’ordre standard on peut déclarer une fonction de comparaison cmp int et un
tableau d’entiers t et appeler la fonction qsort. Mais on peut aussi déclarer une fonction de
comparaison sur les caractères cmp char avec un ordre alphabétique décroissant et un tableau
de caractères a et y appliquer la même fonction qsort.
1 int cmp_int ( const void *p , const void * q ){
2 int x =*( const int *)( p );
3 int y =*( const int *)( q );
4 if (x < y ){
5 return -1;}
6 else {
7 if ( x == y ){
8 return 0;}
Pointeurs 75
9 else {
10 return 1;}}}
11 int cmp_char ( const void *p , const void * q ){
12 char x =*( const char *)( p );
13 char y =*( const char *)( q );
14 if (x > y ){
15 return -1;}
16 else {
17 if ( x == y ){
18 return 0;}
19 else {
20 return 1;}}}
21 void main (){
22 int t [5] = {4 ,4 ,1 ,0 ,3};
23 qsort (t , ( size_t )5 , sizeof ( int ) , cmp_int );
24 char a [4] = { ’a ’ , ’d ’ , ’c ’ , ’a ’};
25 qsort (a , ( size_t )4 , sizeof ( char ) , cmp_char );}
Une deuxième solution consiste à utiliser les opérateurs C de la bibliothèque stdio.h qui rem-
placent l’entrée standard par le fichier input et la sortie standard par output. Cette deuxième
solution est plus flexible et elle ne dépend pas du système d’exploitation. Voici un exemple
de programme.
1 void main (){
2 int x ; FILE * f ;
3 f = fopen (" input " ," r "); // f pointe vers input
4 fscanf (f ,"% d " ,& x );
5 fclose ( f ); // f ne pointe plus vers input
6 f = fopen (" output " ," w "); // f pointe vers output
7 fprintf (f ,"% d \ n " , x +1);
8 fclose ( f );} // f ne pointe plus vers output
Supposons maintenant que l’on souhaite passer à l’exécutable des arguments. Par exemple,
les noms des fichiers qu’il doit lire/écrire comme dans :
Jusqu’à maintenant, on a supposé que la fonction main ne prend par d’arguments. Ce-
pendant, il est possible de déclarer des arguments pour cette fonction comme dans l’exemple
suivant.
76 Pointeurs
Exercice 10 Voici un exercice qui permet d’utiliser les différents aspects des pointeurs décrits
dans ce chapitre. Il s’agit de reprogrammer la fonction sort de Unix.
— Ouvrez un fichier input.
— Comptez le nombre de lignes dans le fichier input et le nombre maximum de caractères
par ligne.
— Allouez un tableau qui contient tous les caractères du fichier et un tableau qui contient
les pointeurs au debut de chaque ligne dans le premier tableau.
— Utilisez la fonction de bibliothèque qsort pour trier le tableau de pointeurs en suivant
l’ordre alphabétique.
— Imprimez les lignes ordonnées dans un fichier output.
Chapitre 10
En C, il est possible de déclarer des types structure qui contiennent des types pointeurs
à la structure qu’on est en train de déclarer. Il s’agit d’une forme de définition récursive
(au niveau des types plutôt qu’au niveau des fonctions). Ce type de déclarations ouvre la
possibilité de représenter des données avec des formes plus ou moins élaborées : des listes,
des arbres, des graphes,. . . Dans ce chapitre introductif, on se limitera à considérer les listes
qu’on peut visualiser comme des suites d’éléments constitués d’une valeur et d’un pointeur
vers le prochain élément de la suite. Il est naturel de considérer des listes dont la taille varie
dynamiquement pendant le calcul et dans ce contexte on est améné à reconsidérer le modèle
mémoire de C. Il est aussi naturel d’adapter aux listes les algorithmes qui utilisent les tableaux
et de représenter des ensembles finis comme des listes.
10.1 Listes
En C, on peut déclarer par exemple :
1 struct node { int val ; struct node * next ;};
2 struct tnode { int tval ; struct tnode * left ; struct tnode * right ;};
La structure node contient un champ next qui est un pointeur à une structure node et
la structure tnode contient deux champs left et right qui sont des pointeurs à une structure
tnode. On peut utiliser node pour représenter des listes et on verra plus tard qu’on peut utiliser
tnode pour représenter des arbres binaires. En C on peut voir une liste non vide (d’entiers)
comme une collection de valeurs de type struct node avec adresses `1 , . . . , `n telle qu’il existe
une permutation π sur {1, . . . , n} avec la propriété que :
`π(1) → next = `π(2)
`π(2) → next = `π(3)
···
`π(n−1) → next = `π(n)
`π(n) → next = NULL
77
78 Listes et gestion de la mémoire
Une telle structure peut mémoriser jusqu’à 1000 entiers et au cas où elle serait saturée il est
possible d’allouer une autre structure du même type et de la connecter à la précédente. De
cette façon on pourra continuer à lire et mémoriser la suite d’entiers tant que la mémoire
(virtuelle) de l’ordinateur contient un segment suffisant à contenir une valeur de type struct
tabnode (typiquement 4004 octets). On peut remarquer qu’on paye un petit prix pour cette
flexibilité : environ 1 octet sur 1000 est utilisé pour mémoriser les pointeurs du champ next.
Dans notre exemple, on doit allouer dynamiquement (pendant le calcul) des structures de
type tabnode. En C, pour allouer une structure on utilise la fonction malloc de la bibliothèque
stndlib.h. 1 Il convient d’encapsuler la fonction malloc dans une fonction C. Par exemple, pour
allouer une structure de type node on peut utiliser la fonction suivante.
La fonction sizeof est une autre fonction de bibliothèque qui prend en entrée un type et
retourne un nombre naturel qui indique le nombre d’octets nécessaires pour mémoriser une
valeur du type en question. Par example, pour le type node ce nombre est typiquement 8.
La fonction malloc retourne un pointeur vers void qui est l’adresse de base du segment de
mémoire alloué. Remarquons aussi que la fonction allocate node initialise les champs val et
next de la structure.
On rappelle aussi la fonction qui alloue une structure node en utilisant la fonction malloc.
1 node * allocate_node ( int v ){
2 node * p =( node *)( malloc ( sizeof ( node )));
3 (* p ). val = v ;
4 (* p ). next = NULL ;
5 return p ;}
On va supposer que les entiers dans l’ensemble sont mémorisés dans la liste par ordre
croissant. Un escamotage qui permet de simplifier la programmation des opérations consiste
à créer deux noeuds sentinelles qui contiennent des entiers non-standard −∞ et +∞. En
pratique, on peut utiliser les constantes INT MIN et INT MAX de la bibliothèque limits.h. La
liste qui correspond à l’ensemble vide va donc contenir deux noeuds avec valeurs INT MIN et
INT MAX. La fonction C qui permet de créer l’ensemble vide est la suivante.
1 node * emp (){
2 node * head = allocate_node ( INT_MIN );
3 node * tail = allocate_node ( INT_MAX );
4 (* head ). next = tail ;
5 return head ;}
Les deux opérations plus compliquées sont celles pour insérer et éliminer. Elles ont une
structure assez similaire qui consiste à faire glisser deux pointeurs pred et curr dans la liste
jusqu’à trouver le point où l’action d’insertion ou d’élimination doit avoir lieu. On remarquera
que l’insertion utilise la fonction malloc et l’élimination la fonction free.
1 void ins ( int v , node * list ){
2 node * pred = list ;
3 node * curr =( list - > next );
4 while (( curr - > val ) < v ){
5 pred = curr ;
6 curr =( curr - > next );}
Listes et gestion de la mémoire 81
Exercice 11 Programmez les fonctions pour tester l’appartenance et pour imprimer un en-
semble.
Exercice 12 Reprogrammez les fonctions ins et rem en supposant que maintenant on n’a pas
de noeuds sentinelles et que donc l’ensemble vide correspond à la liste vide.
Exercice 13 On souhaite représenter des ensembles d’entiers avec au plus n éléments. Fixez
une représentation de ces ensembles par des tableaux d’entiers et étudiez la mise en oeuvre
des opérations emp, ins, mem, rem et pri évoquées au début de cette section.
Exercice 14 Un multiensemble est un ensemble où chaque élément peut être dupliqué un
certain nombre de fois. Formellement, un multiensemble sur un ensemble support A est une
fonction m : A → N. Le nombre naturel m(a) indique le nombre de copies disponibles de
l’élément a ; on dit aussi la multiplicité de a. Un multiensemble m est fini si {a ∈ A |
m(a) > 0} est fini. On peut effectuer sur les multiensembles finis des opérations similaires à
celles évoquées pour les ensembles finis. La différence est que l’opération d’insertion augment
de 1 la multiplicité d’un élément et l’opération d’élimination la diminue de 1 (si elle est
positive). On prend le support A comme l’ensemble des entiers. Proposez une représentation
des multiensembles d’entiers par des listes.
82 Listes et gestion de la mémoire
Chapitre 11
Piles et queues
On introduit deux exemples élémentaires de structures de données : les piles et les queues.
Une structure de données est un peu l’analogue informatique d’une structure algébrique
(groupes, anneaux,. . .) : on y trouve des données et un certain nombre de fonctions pour
les manipuler. Un principe de base de la modularisation des programmes consiste à concevoir
des structures de données dans lesquelles on distingue une représentation externe visible à
l’utilisateur et une représentation interne qui devrait être invisible à l’utilisateur. Dans ce
contexte, les structures de données constituent un élément essentiel pour la modularisation
d’un programme. On présente une technique de programmation qui permet de réaliser cette
idée en C et on termine en discutant deux applications des structures de données introduites.
83
84 Piles et queues
11.2 Modularisation
En C, pour pallier à l’absence d’un mécanisme de définition d’une interface on effectue
un découpage (assez pénible) du programme en fichiers et on décore certaines fonctions et
variables avec le mot static.
Une déclaration de variable ou de fonction qui est précédée par le mot static est visible
seulement dans le fichier où se trouve la déclaration. Par ailleurs, une variable static déclarée
dans une fonction est initialisée une seule fois et elle garde sa valeur d’un appel au suivant.
Ainsi elle se comporte comme une sorte de variable globale qui est visible seulement par la
fonction.
Comme exemple, on considère la construction d’un module pour une pile d’entiers. On
commence par définir un fichier stack.h qui contient les éléments suivants :
1 # ifndef STACK_H
2 # define STACK_H
3 typedef int item ;
4 extern void init_stack ( int );
5 extern short empty_stack ();
6 extern void insert_stack ( item );
7 extern item elim_stack ();
8 # endif
Le fichier stack.h pourrait être importé par d’autres fichiers plusieurs fois et dans ce cas
les lignes 1, 2, 8 assurent que les définitions dans le fichier stack.h seront prises en compte une
seule fois. Ces lignes ne sont pas vraiment utiles dans l’exemple en question mais c’est une
bonne pratique de les mettre dans les fichiers .h pour éviter des ennuis dans des situations
plus compliquées.
Dans 3, on déclare le type item des éléments qui vont constituer la pile ; dans notre cas il
s’agit d’entiers. Dans 4 − 7, on déclare les prototypes des fonctions pour la gestion de la pile
qu’on qualifie de fonctions extern.
On associe au fichier stack.h un fichier stack.c qui contient la mise en oeuvre de la pile.
Par exemple, le fichier stack.c pourrait être le suivant. Notez que dans (1) on inclut le fichier
stack.h. Un fichier, disons user.c qui voudrait utiliser les fonctions de la pile devrait aussi
contenir cette directive.
1 # include < stdio .h >
2 # include < stdlib .h >
3 # include < assert .h >
4 # include " stack . h " \\(1)
5 static item * stack = NULL ;
6 static int head = -1;
7 static int size = -1;
8 void init_stack ( int m ){
9 if ( head == -1){
10 stack = malloc ( sizeof ( item )* m );
11 head =0;
12 size = m ;}}
13 short empty_stack (){
14 if ( head ==0){
15 return 1;}
16 if ( head >0){
17 return 0;}
86 Piles et queues
18 assert (0);}
19 void insert_stack ( item d ){
20 assert ( head <( size -1));
21 if ( head >=0){
22 stack [ head ]= d ;
23 head ++;}}
24 item elim_stack (){
25 assert ( head >0);
26 head - -;
27 return stack [ head ];}
On a maintenant 3 fichiers à traiter : stack.h, stack.h, user.c. En principe, la commande
cc -o user user.c stack.c suffit à produire un exécutable user. Cependant, il n’est pas rare
de se trouver dans des situations où il y a beaucoup plus de fichiers. Dans ces cas, il est
recommandé de déclarer les dépendances entre les fichiers dans un fichier Makefile et de
laisser la commande make s’occuper de la compilation. Dans le cas en question, le contenu du
fichier Makefile pourrait être le suivant :
CC=gcc
CFLAGS=-Wall -std=c11
LDLIBS= -lm
ALL = user
user : user.o stack.o
user.o : user.c
stack.o : stack.c
11.3 Applications
On discute deux exemples d’application des structures pile et queue.
Exemple 37 On a vu dans la section 1.2 que l’interprétation d’un programme C utilise une
pile de blocs d’activation : à chaque appel de fonction on empile le bloc de la fonction appelée et
à chaque retour de fonction on dépile le bloc qui se trouve au sommet de la pile. En particulier,
cette pile permet de comprendre le fonctionnement des fonctions récursives (chapitre 5).
En principe, il est possible de se passer des appels récursifs mais le prix à payer est
une gestion explicite de la pile. Pour illustrer la méthode on reprend l’exemple de la tour
d’Hanoı̈ (section 5.2).
1 void hanoi ( int n , int p1 , int p2 ){
2 int p3 = troisieme ( p1 , p2 );
3 if ( n ==1){
4 imprimer ( p1 , p2 );}
5 else {
6 hanoi (n -1 , p1 , p3 );
1. Ceci est juste un petit aperçu des possibilités offerte par l’outil make.
Piles et queues 87
7 imprimer ( p1 , p2 );
8 hanoi (n -1 , p3 , p2 );}
9 return ;}
Pour transformer cette fonction récursive en une fonction itérative (avec des boucles mais
sans appels récursifs), on va introduire une pile qui contient des triplets (n, p, p0 ) où n est
la hauteur de la tour qu’on veut déplacer et p et p0 sont deux pivots différents. On va donc
redéfinir le type item du fichier stack.h de la section précédente comme suit :
1 struct han { int hauteur ; int pivot1 ; int pivot2 ;};
2 typedef struct han item ;
On est maintenant prêt à introduire la version itérative de la fonction hanoi. Dans (1)
on initialise une pile assez grande (exercice !), dans (2) on insère dans la pile le triplet qui
correspond au problème initial, à partir de (3), tant que la pile est non-vide, on extrait un
problème et on distingue deux situations :
— si la tour a hauteur 1 on imprime directement la solution,
— sinon on empile trois sous-problèmes ; le lecteur remarquera que l’ordre d’empilement
est inversé par rapport à l’ordre des appels récursifs dans la fonction hanoi.
1 static void hanoi_it ( int n , int p1 , int p2 ){
2 init_stack (2* n ); //(1)
3 item d ={ n , p1 , p2 };
4 insert_stack ( d ); //(2)
5 while (!( empty_stack ())){ //(3)
6 item c = elim_stack ();
7 if ( c . hauteur ==1){
8 imprimer ( c . pivot1 , c . pivot2 );}
9 else {
10 int p3 = troisieme ( c . pivot1 , c . pivot2 );
11 item b1 ={ c . hauteur -1 , p3 , c . pivot2 };
12 insert_stack ( b1 );
13 item b2 ={1 , c . pivot1 , c . pivot2 };
14 insert_stack ( b2 );
15 item b3 ={ c . hauteur -1 , c . pivot1 , p3 };
16 insert_stack ( b3 );}}}
Le problème est de calculer un tableau d’entiers d tel que d[i] est le nombre minimum de vols
nécessaires à connecter la ville v0 à la ville vi . On appelle ce nombre l’éloignement de vi de
v0 . Par convention, ce nombre est 0 si i = 0 et INT MAX si i 6= 0 et il est impossible d’aller
de v0 à vi . Un algorithme possible est le suivant.
1. On initialise le tableau d comme suit :
0 si i = 0
d[i] =
INT MAX sinon.
88 Piles et queues
89
90 Preuve et test
12.2 Terminaison
Dans ces notes de cours, les preuves de terminaison sont assez directes. Cependant, en
général le problème de savoir si un programme termine est indécidable et il est aussi possible
de construire des programmes simples dont la terminaison est un problème ouvert.
Une stratégie générale pour prouver la terminaison d’un programme est d’interpréter ses
états de calcul dans un ensemble bien fondé.
Prenons comme ordre bien fondé les nombres naturels avec l’ordre standard et définissons :
µ(x, y, z) = (z − x) .
Donc si le programme bouclait on aurait une suite descendante infinie dans N ce qui est
impossible !
Montrez que :
1. La relation >l est transitive.
2. L’ensemble {(x, y) | (2, 2) >l (x, y)} est infini.
3. Néanmoins (N × N, >l ) est un ordre bien fondé. 1
Exercice 16 Les programmes while suivants terminent-t-ils en supposant que les variables
varient sur les nombres naturels positifs ?
while(m 6= n){ while(m 6= n){
if(m > n){ if(m > n){
m = m − n; } m = m − n; }
else{ else{
n = n − m; }} h = m;
m = n;
n = h; }} .
Il faut donc trouver un invariant plus général pour mener à bien la preuve.
Dans certains domaines (logiciels critiques), on assiste à l’introduction de techniques de
preuve formelle de programmes. Ces preuves comportent un grand nombre de détails et elles
sont développées en utilisant des assistants de preuve. Par exemple, l’outil Frama-C, développé
au CEA (http://frama-c.com/), est un assistant de preuve spécialisé pour traiter des pro-
grammes C. Un assistant de preuve comporte un certain nombre de stratégies qui permettent
d’automatiser la synthèse de certaines portions relativement simples de la preuve et d’un petit
programme qui est capable de vérifier la correction de l’intégralité de la preuve.
On peut remarquer que le travail fait pour la preuve de l’algorithme est souvent bénéfique
pour le test. En particulier, pour tester il faut une spécification de ce que le programme est
censé faire. Pour certains tests (white box), il est aussi utile d’avoir un modèle du langage
de programmation, à savoir un modèle de comment le programme exécute. Par exemple,
pour tester tous les branchements du programme. La construction du modèle est un travail
pour les experts. Il doit être simple et en même temps permettre de prédire correctement le
comportement d’une grande partie des programmes.
Une bonne pratique consiste à garder au moins un test pour chaque erreur trouvée dans
le programme et à exécuter à nouveau ce test à chaque modification du programme ; dans ce
contexte on parle de test de non-régression.
Une autre bonne pratique consiste à automatiser la génération et la vérification des tests.
Par exemple, dans le cas du tri par insertion on pourrait générer des permutations aléatoires
avec la méthode de la section 7.3 et vérifier le résultat en utilisant un autre algorithme de tri
ou alors en gardant une bijection entre les éléments dans le tableau en entrée et ceux dans le
tableau trié.
Une fois qu’on a acquis une certaine confiance en la correction fonctionnelle du programme,
on s’attachera à tester sa performance. Par exemple, on chronomètre le temps d’exécution et
l’occupation de mémoire sur des entrées de taille croissante. Pour des programmes qui allouent
dynamiquement de la mémoire dans le tas (voir chapitre 10), on cherchera aussi à détecter
des fuites de mémoire, à savoir des situations dans lesquelles des segments de mémoire qui ne
sont plus utilisés par le programme ne sont pas récupérés.
En général, le programme qu’on teste va interagir avec d’autres programmes qui ne res-
pectent pas forcement sa spécification. Ainsi il est utile de tester le comportement du pro-
gramme sur des entrées qui ne sont pas prévues par la spécification. On dira qu’un programme
est robuste s’il est capable de continuer à fonctionner dans un environnement hostile.
96 Preuve et test
Chapitre 13
Complexité asymptotique
13.1 O-notation
Définition 3 (O-notation) Soient f, g : N → N deux fonctions sur les nombres naturels.
On dit que f est O(g) si :
∃ k, n0 ≥ 0 ∀ n ≥ n0 f (n) ≤ k · g(n) .
97
98 Complexité
traiter, respectivement. On remarque que la fonction cA est bien définie car A termine et il y
a un nombre fini d’entrées possibles de taille au plus n. On note aussi que par définition cA
est croissante : si n ≤ n0 alors cA (n) ≤ cA (n0 ).
Remarque 11 La notation O nous donne une information synthétique sur l’efficacité d’un
algorithme/programme. Mais notez que :
— Il s’agit d’une borne supérieure.
— On considère le pire des cas.
— On cache les constantes. Un algorithme qui prend 3n2 msec est utilisable, un algorithme
qui prend 280 n msec ne l’est pas.
— Le coût d’une opération élémentaire sur une vraie machine peut varier grandement.
Par exemple on peut avoir un facteur 102 entre un cache hit (la donnée est en mémoire
cache) et un cache miss (elle n’y est pas). Les optimisations effectuées par le compila-
teur peuvent avoir un impact important sur la complexité observée.
— Dans les calculs en virgule flottante, on doit aussi se soucier de la stabilité numérique
des opérations.
Pour toutes ces raisons, dans les applications, la borne O doit être confortée par une analyse
plus fine et des tests.
Exemple 44 (tri) Considérons les algorithmes de tri étudiés dans le chapitre 7 et suppo-
sons que les affectations, les branchements, les comparaisons et les opérations arithmétiques
prennent un temps constant O(1). Cette hypothèse est raisonnable si les données qu’on trie
on une taille bornée ; par exemple on manipule des nombres flottants sur 64 bits. Dans ce cas,
les analyses esquissées permettent d’affirmer que les algorithmes de tri à bulles et de tri par
insertion ont une complexité O(n2 ) alors que l’algorithme de tri par fusion a une complexité
O(n log n).
effectue une recherche dite dichotomique de la valeur v dans le tableau t. Si elle trouve v elle
retourne sa position dans le tableau et sinon elle retourne −1.
Que peut-on dire sur la complexité asymptotique de la fonction ? La taille de l’entrée est
proportionnelle au nombre d’éléments du tableau t. Il s’agit donc de compter en fonction de
n et dans le pire des cas, le nombre de pas élémentaires que la fonction va effectuer avant de
retourner le résultat.
On remarque que la fonction contient des affectations, des branchements, des opérations
arithmétiques et une boucle while. Comme pour les algorithmes de tri de l’exemple précédent,
on peut faire l’hypothèse que chaque affectation, branchement et opération arithmétique prend
un temps constant et dans ce cas déterminer la complexité asymptotique de la fonction revient
à déterminer en fonction de n combien de fois la boucle while peut être exécutée dans le pire
des cas. Initialement on a i = 0 et j = len(t) − 1. Donc i ≤ j. A chaque itération de la boucle
si on ne termine pas alors on passe d’un intervalle de recherche [i, j] à un intervalle qui est
soit [i, m − 1] soit [m + 1, j], où m = (i + j)/2 et on sait que dans le premier cas i ≤ m − 1
et dans le deuxième m + 1 ≤ j.
Si on définit la taille d’un intervalle [i, j] comme (j − i) on peut dire que la taille de
l’intervalle de recherche est au moins divisée par deux à chaque itération. Comme on parle
ici de la division entière, il faut travailler un petit peu pour avoir un argument rigoureux. Par
exemple, si on pose j = i + k on a :
— (i + j)/2 − 1 − i = i + (k/2) − 1 − i = (k/2) − 1 ≤ (k/2) = (j − i)/2.
— j − (i + j)/2 − 1 = i + k − i − (k/2) − 1 = k − (k/2) − 1 ≤ (k/2) = (j − i)/2.
On sait aussi que quand la taille de l’intervalle tombe à 0 le programme termine certainement.
Donc le nombre d’itérations dans le pire des cas est de l’ordre de log2 n et on peut résumer
l’analyse en disant que sur un tableau de données triées et de taille bornée la recherche di-
chotomique d’un élément a complexité O(log n).
Addition
L’algorithme pour l’addition du primaire consiste à propager la retenue de droite à gauche
et a un complexité O(n). Si on additionne les chiffres binaires ai et bi avec retenue ri on obtient
un chiffre si pour la somme et une retenue ri+1 comme spécifié dans le tableau suivant :
ai bi ri si ri+1
0 0 0 0 0
0 1 0 1 0
1 1 0 0 1
1 0 0 1 0
0 0 1 1 0
0 1 1 0 1
1 1 1 1 1
1 0 1 0 1
100 Complexité
Pour la retenue initiale on pose r0 = 0 et notez qu’en général il faut n + 1 bits pour
représenter la somme de deux nombres de n bits ; par exemple, 110+101 = 1011. L’algorithme
consiste donc à itérer le calcul de si , ri+1 à partir de ai , bi , ri pour i = 0, . . . , n − 1. Chaque
itération prend un temps constant et on peut donc affirmer que l’algorithme est O(n). Avec
un choix approprié de la représentation des nombres, on peut utiliser le même algorithme
pour les nombres entiers et donc traiter la soustraction.
Multiplication
L’algorithme pour la multiplication du primaire consiste à multiplier le premier nombre
par chaque chiffre du deuxième nombre et ensuite à additionner les résultats en effectuant un
décalage approprié. Par exemple :
110 ×
101 =
110
0000
11000
11110
Exponentiation
Considérons maintenant la situation pour la fonction d’exponentiation. Avec n bits on
représente les nombres dans l’intervalle [0, 2n − 1]. Si on prend x ∈ [0, 2n − 1] on aura
n
2x ∈ [1, 22 −1 ] et il faudra environ 2n bits pour représenter le résultat. Avec une représentation
standard des nombres, tout algorithme qui calcule la fonction exponentielle prendra au moins
un temps exponentiel. En effet la simple écriture du résultat peut prendre un temps expo-
nentiel.
Soient a et e des nombres naturels. Combien de multiplications faut il pour calculer l’ex-
posant ae ? Un algorithme possible est de calculer :
Cet algorithme effectue e − 1 multiplications ce qui est exponentiel dans log2 e (à savoir la
taille de e !). Mais il y a une autre méthode de calcul dites des carrés itérés. Soit
e = Σi=0,...,k ei 2i
l’expansion binaire de e. Donc ei ∈ {0, 1}. On applique les propriétés de l’exposant pour
dériver :
i i i
ae = aΣi=0,...,k ei 2 = Πi=0,...,k (a2 )ei = Π0≤i≤k,ei =1 (a2 ) .
On a alors l’algorithme suivant :
Complexité 101
i
1. On calcule a2 pour 0 ≤ i ≤ k. En remarquant que
i+1 i
a2 = (a2 )2 .
(ae ) mod m
Exemple 46 On souhaite calculer 325 mod 7. Dans la suite, toutes les congruences sont
modulo 7. En base 2, la représentation de 25 est 11001. On calcule les carrés itérés :
0 1 2 3 4
32 ≡ 3, 32 ≡ 2, 22 ≡ 4, 22 ≡ 2, 22 ≡ 4,
325 ≡ 316 · 38 · 31 ≡ 4 · 2 · 3 ≡ 3 .
Remarque 12 La borne O(k 3 ) est une borne supérieure. En effet, on peut faire un peu
mieux. Par exemple, avec l’algorithme de Karatsuba on peut multiplier deux nombres de k
chiffres en O(k 1,59 ), au lieu de O(k 2 ). Par ailleurs, l’algorithme présenté est pratique ; il est
couramment utilisé dans les applications cryptographiques avec k ≈ 103 .
Techniquement la fonction rand est basée sur une congruence linéaire et génère toujours la
même suite (SIC). Si l’on veut changer la suite générée il faut initialiser un germe en exécutant
par exemple la commande :
srand((unsigned)(time(NULL)));
qui va faire dépendre la suite du temps courant (time est une fonction de la bibliothèque
htime.hi).
La qualité des générateurs aléatoires des langages de programmation est très variable. En
particulier, le générateur du langage C qu’on vient de décrire rend service pour le test ou la
simulation mais il n’est pas du tout adapté aux applications cryptographiques.
Permutations aléatoires
On rencontre souvent le problème suivant : à partir d’un générateur aléatoire de nombres,
il faut concevoir un programme qui génère des structures avec une certaine distribution. Ici
on considère le problème de générer des permutations avec une distribution uniforme. On va
représenter une permutation p : In → In , où In = {0, . . . , n − 1}, par un tableau p qui contient
chaque entier dans In exactement une fois.
Premier essai Considérez la fonction permall suivante qui génère une permutation d’un
tableau. On suppose que randint(0, n − 1) nous donne un entier dans In avec une distribution
uniforme.
1 void permall ( int t [] , int n ){
2 int i ;
3 for ( i =0; i < n ; i ++){
4 int j =( rand ()% n );
5 int temp = t [ i ];
6 t [ i ]= t [ j ];
7 t [ j ]= temp ;}}
La fonction permall génère-t-elle une permutation avec une distribution uniforme ? La réponse
est négative !
Analyse
— Chaque chemin d’exécution demande la génération de n nombres entiers dans In .
— On a donc nn chemins possibles et chaque chemin a probabilité n1n .
1 k
— Comme on a n! permutations, si la distribution était uniforme on devrait avoir n! = nn
n
pour k ∈ N. Soit : n = kn!
— Contradiction ! Par exemple, en prenant n = 3.
Preuve. Pour k = 0, S est la séquence vide et t[0] · · · t[k − 1] est aussi la séquence vide. Par
ailleurs (n−0)!
n! = 1.
On suppose la propriété vraie pour k < n−1. Soit S = S 0 v une (k+1)-séquence. On sait que
t[0] · · · t[k − 1] = S 0 avec probabilité (n−k)! 1
n! . Par ailleurs, on a t[k] = v avec probabilité n−k
0
puisque l’élément est choisi parmi les n − k qui ne sont pas déjà dans S . Donc la probabilité
que t[0] · · · t[k] = S est :
(n − k)! 1 (n − (k + 1))!
= .
n! n − k n!
On peut donc conclure que la fonction permplace génère une permutation du tableau avec
une probabilité uniforme : chaque n-séquence est générée avec probabilité n!1
. 2
Exemple 47 (test performance tri) Supposons que l’on souhaite comparer les performan-
ces de l’algorithme de tri par insertion et du tri par fusion (chapitre 7) En supposant une
distribution uniforme des entrées, on peut utiliser la fonction permplace pour générer les
entrées. Par exemple, supposons que pour des tableaux de taille n on effectue m tests. Pour
avoir un sens de comment le temps de calcul varie avec la taille des tableaux on va commencer
avec des tableaux de petite taille et ensuite on va doubler la taille à chaque cycle de tests tant
que le temps de réponse reste raisonnable. A titre indicatif, on peut prendre m = 10 et faire
varier n entre 25 et 215 .
Exemple 48 Supposons disposer d’un tableau P qui contient les nombres premiers compris
entre 2 et p. Si on tire un nombre x compris entre 2 et p2 combien de divisions faut-il faire
pour savoir s’il est premier ? Ici on suppose qu’on considère les nombres premiers du tableau
P par ordre croissant. Dans le pire des cas, si le nombre est premier et proche de p2 le nombre
de divisions est environ la taille du tableau P . Cependant si on suppose que le nombre est tiré
avec probabilité uniforme on s’attend à faire beaucoup moins de divisions. Par exemple, pour
les nombres pairs une seule division suffira !
Une deuxième approche consiste à considérer le coût d’une suite d’opérations au lieu d’une
seule opération et à considérer le coût d’une opération comme la moyenne arithmétique des
coûts dans le pire des cas des opérations de la suite. Dans ce cas on parle de complexité amortie.
Notez qu’on ne fait pas d’hypothèse sur la distribution des entrées et plus en général le calcul
des probabilités ne joue pas de rôle dans la complexité amortie. On considère toujours le pire
des cas mais par rapport à une longue suite d’opérations plutôt qu’à une seule opération.
Exemple 49 On considère un tableau de m éléments dans lequel on peut effectuer les opérations
suivantes :
— lire un élément,
— modifier un élément,
— ajouter un élément à la fin du tableau.
On suppose que initialement on alloue un segment de mémoire qui peut contenir n = 1
éléments et que chaque fois que le nombre d’éléments m dépasse la capacité du segment n
on double la capacité du segment. Le coût d’une opération sans dépassement est 1 et le coût
d’une opération avec dépassement est la taille du segment (on imagine qu’il faut copier tous
les éléments dans un segment deux fois plus grand). On considère une suite de p opérations
dont le coût est c1 , . . . , cp . Que peut-on dire sur la moyenne arithmétique des coûts, à savoir :
1
(Σi=1,...,p ci )
p
dans le pire des cas pour p qui tend vers ∞ ? Tant qu’on effectue des opérations de lecture et
modification la moyenne des coûts est 1. Pour trouver le pire des cas on a intérêt à maximiser
le nombre d’opérations d’ajout qui sont potentiellement coûteuses. Considérons donc une suite
d’opérations d’ajout où par simplicité p = 2k . On obtient :
Donc la moyenne arithmétique tend vers 2 et on peut considérer que le coût amorti de chaque
opération est constant (on paye 2 pour chaque opération).
Chapitre 14
Soit H un ensemble fini d’éléments que l’on peut comparer avec un ordre total. On cherche
une façon de représenter H qui nous permet d’effectuer (au moins) les opérations suivantes
de façon efficace :
— insertion d’un élément dans H,
— élimination du plus grand élément de H.
Si l’on garde H totalement ordonné alors on peut extraire un élément en O(1) mais l’inser-
tion d’un élément est en O(n). D’autre part, si on ignore l’ordre alors on peut insérer en O(1)
et extraire en O(n). En moyenne, on s’attend à faire autant d’insertions que d’éliminations et
donc les deux solutions demandent un temps linéaire dans le nombre d’éléments dans H. La
structure tas (ou heap en anglais) 1 qu’on va introduire dans ce chapitre va nous permettre
d’effectuer ces opérations en temps logarithmique.
Le tas est un premier exemple de structure de données non triviale. De telles structures
sont un outil essentiel dans la conception d’algorithmes efficaces.
Définition 6 (arbres binaires) L’ensemble T est le plus petit ensemble tel que :
— nil ∈ T (l’arbre vide).
— Si t1 , t2 ∈ T et n ∈ Z alors (n, t1 , t2 ) ∈ T .
105
106 La structure tas
graphique, on associe à t un noeud qui contient la valeur n et qui est connecté par une arête
gauche et une arête droite aux représentations graphiques de t1 et t2 , respectivement. Le
noeud associé à t est le père des noeuds associés au noeuds t1 et t2 (qui eux sont les fils).
Le premier noeud généré dans cette construction est désigné en tant que racine de l’arbre.
Par convention, on ne représente pas les arbres vides (nil) et on dit qu’un noeud qui n’a pas
de sous-arbres (non-vides) est une feuille. Le noeud racine est une feuille si et seulement si
l’arbre comporte un seul noeud. Typiquement, on dessine les arbres à l’envers, c’est-à-dire
avec les feuilles en bas et la racine en haut.
Dans la suite un arbre est un arbre d’après la définition ci-dessus. Techniquement, il s’agit
d’arbres enracinés (on désigne un noeud racine), binaires (un noeud a au plus deux fils),
ordonnés (on distingue le fils gauche du fils droit) et avec des valeurs associées aux noeuds. 2
Définition 7 (hauteur) La hauteur h d’un arbre est le nombre d’arêtes qu’il faut traverser
dans le chemin le plus long de la racine à une feuille.
Preuve. Pour avoir un chemin avec h arêtes il faut (h+1) noeuds. D’autre part, on maximise
le nombre de noeuds en supposant que chaque noeud qui n’est pas une feuille a deux fils. On
atteint ainsi la borne supérieure (preuve par récurrence sur h). 2
Définition 8 (arbre plein) Un arbre est plein si tous les noeuds qui ne sont pas des feuilles
ont deux fils.
Définition 9 (arbre complet) Un arbre est complet s’il est plein et toutes les feuilles sont
à la même profondeur (la longueur du chemin de la racine à une feuille est constant).
Définition 10 (positions) On peut compter les positions des noeuds d’un arbre de la façon
suivante (par convention, on compte de 1) :
Niveau Position
0 1
1 2, 3
2 4, 5, 6, 7
3 8, 9, 10, 11, 12, 13, 14, 15
··· ···
h 2h , . . . , (2h+1 − 1)
On peut représenter un arbre quasi-complet avec des pointeurs. Si l’on connaı̂t le nombre
maximal de noeuds dans l’arbre une solution plus économe en mémoire est d’utiliser un
tableau. En effet, un arbre quasi-complet avec n noeuds est en correspondance bijective avec
un tableau de n éléments dont les cellules 1, . . . , n contiennent les valeurs associées aux noeuds :
2. On utilisera cette notion d’arbre aussi dans le chapitre 18 (arbres binaires de recherche) et la section 21.2
(compression de Huffman). Par contre dans les chapitres 23 et 24 (sur les graphes) on introduira une autre
notion d’arbre.
La structure tas 107
— Les fils du noeud en position i (s’ils existent) sont dans les positions 2i et 2i + 1.
— Le père d’un noeud en position i > 1 est en position i/2.
— Un arbre quasi-complet avec n noeuds a hauteur h = blog nc et ses feuilles sont aux
positions (n/2) + 1, . . . , n.
Remarque 13 Une définition duale est possible où l’on stipule que le père a une valeur
inférieure ou égale à celle des fils. Si l’on veut distinguer les deux situations on parlera de
max-tas et de min-tas. Dans la suite on se focalise sur les max-tas (la racine contient la valeur
la plus grande). Tout ce qu’on fait peut être adapté de façon évidente aux min-tas.
On fait l’hypothèse que l’on dispose d’un tableau a avec n cellules numérotées de 1 à n
et d’une variable m qui enregistre le nombre de cellules occupées. On a donc 0 ≤ m ≤ n et
on peut représenter de cette façon tous les arbres quasi-complets avec m éléments. On décrit
maintenant la mise-en-oeuvre des opérations principales et leur complexité.
Insertion Possible seulement si m < n. On incrémente m, on ajoute l’élément inséré au
fond du tableau et on le fait remonter autant que nécessaire en le comparant à son père.
La comparaison et éventuellement l’échange avec le père se fait en O(1). Le nombre
de comparaisons est borné par la hauteur de l’arbre. On a donc un coût O(log m).
Élimination Possible seulement si 0 < m. On récupère l’élément en position 1. Si m > 1
on place l’élément en position m en position 1 et on le fait descendre autant que
nécessaire dans l’arbre en le permutant avec son fils le plus grand. On décrémente m.
A nouveau, la comparaison et éventuellement l’échange avec le fils se fait en O(1).
Le nombre de comparaisons est borné par la hauteur de l’arbre. On a donc un coût
O(log m).
Dans la mise en oeuvre de l’opération d’élimination, il convient de définir une fonction
récursive heapify qui effectue le travail suivant : en supposant que t = (n, t1 , t2 ) est un arbre
quasi-complet et t1 , t2 sont des tas elle transforme t dans un tas. En pratique, la fonction
heapify prend en argument l’indice i de la racine de l’arbre quasi-complet et suppose que
les sous-arbres de racine 2 · i et 2 · i + 1, s’ils existent, sont des tas. Ainsi dans l’opération
d’élimination, la phase de descente de l’élément en position 1 est réalisée par un appel hea-
pify(1).
Plus en général, la fonction heapify peut être utilisée pour programmer une fonction build-
heap qui transforme un tableau de m éléments en un tas. On sait que les éléments en position
m/2 + 1, . . . , m sont des feuilles (et donc des tas). Il suffit donc d’appliquer la fonction hea-
pify aux éléments qui se trouvent aux positions m/2, m/2 − 1, . . . , 1. Dans ce cas, à chaque
application de heapify(i) on sait que les sous-arbres dont les racines sont aux positions 2i et
2i + 1 sont déjà des tas et on fait en sorte que le sous-arbre de racine i le devienne aussi.
Cette opération build-heap effectue m/2 appels à la fonction heapify. Le coût de chaque appel
dépend de la hauteur de l’arbre. A priori, on sait que cette hauteur est bornée par log m et
donc le coût de build-heap est O(m · log m). Cependant, on va voir qu’une analyse plus fine
permet d’avoir une borne O(m).
108 La structure tas
Preuve. Comme on l’a déjà remarqué, le coût de heapify est au plus la hauteur h de l’arbre
et h ≤ log2 m. Mais cette borne n’est pas très satisfaisante car la plus part des noeuds ne sont
pas très hauts !
Niveau Position Coût
0 1 h
1 2, 3 (h − 1)
2 4, 5, 6, 7 (h − 2)
3 8, 9, 10, 11, 12, 13, 14, 15 (h − 3)
··· ··· ···
h 2h , . . . , (2h+1 − 1) 0
On doit évaluer :
(h−i) i
Σi=0,...,h 2i (h − i) = 2h Σi=0,...,h 2(h−i)
= 2h Σi=0,...,h 2i
.
On sait que Σi=0,...,h 21i est une constante (série géométrique). On montre que Σi=0,...,h i
2i
est
une constante aussi. On sait que pour 0 ≤ x < 1 :
1
Σi=0,...,∞ xi = .
1−x
Si on dérive, on obtient (un théorème d’analyse assure ici que la dérivée de la somme est égale
à la somme des dérivées) :
1
Σi=1,...,∞ i · xi−1 = .
(1 − x)2
1
Si on multiplie par x et on pose x = 2 :
i i 1/2
Σi=0,...,h i
≤ Σi=1,...,∞ i = =2.
2 2 (1 − 1/2)2
2
Remarque 14 Dans notre analyse, on s’est limité à l’efficacité de chaque opération dans le
pire des cas. Pour d’autres structures de données, d’autres analyses peuvent être plus perti-
nentes. Par exemple, on peut s’intéresser à l’efficacité d’une suite de n opérations (on parle
d’analyse amortie). La raison est qu’il peut y avoir une dépendance entre les opérations. Par
exemple, on peut imaginer qu’une opération coûteuse peut avoir lieu seulement si beaucoup
d’opérations pas chères ont eu lieu auparavant.
14.3 Applications
Tri par tas (heapsort)
En utilisant la structure tas on peut concevoir un (autre) algorithme qui trie un tableau
de n éléments en O(n · log n) dans le pire des cas.
— L’algorithme prend en entrée un tableau a avec n éléments aux positions 1, . . . , n.
La structure tas 109
— On appelle la fonction build-heap sur ce tableau avec un coût : O(n). L’élément plus
grand se trouve alors en a[1]. On pose m = n (m est le nombre d’éléments dans le tas).
— On itère n − 1 fois pour un coût total qui est O(n log n) :
1. on échange a[1] avec a[m].
2. on décrémente m et on applique heapify(1).
— A la fin de l’itération les éléments sont triés par ordre croissant.
Queue de priorité
Dans la simulation d’un système à événements discrets, chaque événement a une date (la
date à laquelle l’événement doit avoir lieu). On place les événements dans un min-heap (le
plus petit est au sommet).
Un pas de simulation consiste à éliminer du tas l’événement au sommet (le plus proche dans
le futur) et à insérer dans le tas un nombre (qu’on suppose borné) de nouveaux événements
(avec les nouvelles dates). Ainsi, dans un tas de taille m, chaque pas de simulation prend
O(log m).
Codage, graphes
On trouvera d’autres utilisations de la structure tas dans la suite du cours. Notamment
dans la construction de l’arbre de codage de Hufmann (section 21.2) et dans la recherche des
plus courts chemins dans un graphe (section 24.3).
110 La structure tas
Chapitre 15
Diviser pour régner (divide and conquer en anglais, divide et impera en latin) est une
stratégie générale pour la conception d’algorithmes.
— Si le problème est petit le résoudre directement.
— Sinon, découper le problème en sous-problèmes de taille comparable (si possible).
— Résoudre les sous-problèmes en appliquant la même stratégie.
— Dériver une solution pour le problème de départ.
L’analyse de complexité d’algorithmes conçus en suivant cette stratégie revient à expli-
citer la fonction qui satisfait une certaine relation de récurrence. Dans ce chapitre on donne
une méthode pour résoudre une certaine classe de relations de récurrence et on décrit des
algorithmes qui appliquent la stratégie diviser pour régner. Un autre exemple majeur sera
discuté dans le chapitre 16.
Notation La notation O(g) définie dans le chapitre 13 dénote un ensemble de fonctions qui
sont bornées au sens asymptotique par la fonction g. En pratique, on abuse souvent cette
notation. Par exemple, on écrit C(n) = O(g) pour dire que la fonction C(n) est dans O(g).
On écrit aussi C(n) + O(g) pour indiquer une fonction qui est bornée au sens asymptotique
par une fonction de la forme C(n) + k · g(n). Ainsi la récurrence (15.1) ci-dessus dit qu’ils
111
112 Relations de récurrence
Exemple 51 On considère un algorithme de tri par fusion (mergesort) qui opère sur un
tableau.
— Si le tableau a taille 1, le tableau est trié.
— Sinon, on sépare le tableau en deux parties égales et on les trie.
— Ensuite on fait une fusion des deux tableaux triés.
La phase de division prend O(1) et la phase de fusion O(n). On dérive donc une relation de
récurrence :
C(n) = 2 · C(n/2) + O(n) .
Exemple 53 C’est un peu la même histoire que pour la multiplication d’entiers mais en
dimension 2 ! On veut multiplier deux matrices A, B de dimension n×n (n pair). L’algorithme
standard est O(n3 ). Une stratégie diviser pour régner commence par décomposer A et B en :
A11 A12 B11 B12
A= B=
A21 A22 B21 B22
où :
C11 = A11 · B11 + A12 · B21
C12 = A11 · B12 + A12 · B22
C21 = A21 · B11 + A22 · B21
C22 = A21 · B12 + A22 · B22 .
On vérifie que : C = A · B. On a donc un coût :
Encore une fois, ceci est plus compliqué que l’algorithme standard et a la même complexité
asymptotique. Cependant, V. Strassen [Str69] a montré que l’on peut s’en sortir avec 7 mul-
tiplications (détails non-triviaux dans [CLRS09]). On a donc :
Preuve de la proposition 6
Pour simplifier la notation, on suppose que :
— n est une puissance de b.
— k borne les constantes du cas terminal et du travail de division et de combinaison. On
a donc :
C(n) ≤ a · C(n/b) + k · nc ,
C(0) ≤ k .
Considérons maintenant le travail de division et combinaison qu’on effectue à chaque niveau :
niveau travail
0 a0 · k · nc
1 a1 · k · (n/b1 )c
··· ···
j a · k · (n/bj )c
j
··· ···
logb n alogb n · k · (n/blogb n )c
Il en suit que le travail tj au niveau j est :
tj = k · nc · rj
a
où r = bc . Le ratio fait dont son apparition ! On distingue maintenant les 3 cas.
rh+1 − 1 r
Σj=0,...,h rj = ≤ rh .
r−1 r−1
Donc pour k 0 = r/(r − 1) on a :
Σj=0,...,logb n tj ≤ k · nc · rh · k 0 .
En explicitant h et r :
a logb n alogb n
k · k 0 · nc · = k · k 0 · nc · .
bc nc
logb x
Rappel : loga x = logb a . Donc :
Exercice 17 On peut représenter un nombre entier de taille arbitraire comme une liste de
chiffres en base B. Par simplicité, supposons B = 10. Programmez les opérations d’addition
et multiplication de l’école primaire ainsi que la multiplication de Karatsuba et déterminez le
nombre de chiffres nécessaires pour que la multiplication de Karatsuba soit plus efficace que
la multiplication de l’école primaire en pratique.
116 Relations de récurrence
Chapitre 16
Un polynôme peut être représenté par ses coefficients ou par un ensemble de points.
On peut voir la transformée (discrète) de Fourier comme une méthode pour passer de la
représentation par coefficients à celle par points alors que la transformée inverse de Fou-
rier passe des points aux coefficients. Un algorithme direct permet de mettre en oeuvre ces
opérations en O(n2 ). En choisissant les points comme les racines n-aires de l’unité, il est pos-
sible, en suivant une stratégie diviser pour régner (voir chapitre 15), de réduire la complexité
à O(n · log(n)) [CT65]. Dans ce cas, on parle de transformée (ou transformée inverse) rapide
(Fast Fourier Transform ou FFT en anglais). Cet algorithme joue un rôle très important, par
exemple, dans le traitement numérique du signal.
x20 xn−1
1 x0 ··· 0
1 x1 x21 ··· xn−1
1
··· ···
(16.2)
··· ···
1 xn−1 x2n−1 ··· xn−1
n−1
Il suit que si les points x0 , . . . , xn−1 sont différents alors la matrice Vn est inversible.
117
118 FFT
Πj6=i (x − xj )
`(x) = Σi=0,...,n−1 yi .
Πj6=i (xi − xj )
On peut aussi dériver le fait qu’un polynôme non-nul de degré n − 1 a au plus n − 1 racines
différentes.
Proposition 9 Un polynôme p(x) de degré n − 1 qui n’est pas nul partout admet au plus
n − 1 points x1 , . . . , xn−1 tels que p(xk ) = 0 pour k = 1, . . . , n − 1.
La valeur θ représente l’angle qui détermine le point sur le cercle. Ainsi la fonction est
périodique de période 2π. En suivant Euler, la fonction s’exprime aussi comme :
On remarquera que :
X 2 = {x2 | x ∈ X} = {ω 2k | k = 0, 1, . . . , n − 1} ,
C(n) = 2 · C(n/2) + n ,
et on sait que C(n) est O(n·log(n)) (chapitre 15). A noter qu’il est essentiel de prendre comme
points les racines n-aires de l’unité. A défaut on ne pourrait pas garantir que les carrés de ces
valeurs constituent un ensemble de cardinale n/2.
xk = ω k , k = 0, . . . , n − 1 .
FFT 121
Soit a = (a0 , . . . , an−1 ) le vecteur des coefficients d’un polynôme de degré au plus n − 1, soit
x = (x0 , . . . , xn−1 ) le vecteur des points et soit y = (y0 , . . . , yn−1 ) le vecteur des images des
points. On a :
Vn a = y . (16.9)
On observe que :
et que pour j 6= ` :
Σk=0,...,n−1 ω jk ω −k` = Σk=0,...,n−1 (ω (j−`) )k
1−(ω (j−`) )n
= 1−ω (j−`)
1−ω n(j−`)
= 1−ω (j−`)
0
= 1−ω (j−`)
=0.
Donc Vn ·V n = n·In , où In est la matrice identité de dimension n. Il suit que : (Vn )−1 = n1 V n . 2
On peut donc appliquer le même algorithme diviser pour régner en O(n · log(n)) pour
calculer V n y. Ensuite on obtient a en divisant le vecteur résultat par n.
Pour ce faire, soit ω une racine 2n-aire de l’unité. On évalue les polynômes pa et pb sur les
points ω 0 , . . . , ω 2n−1 avec la FFT et on calcule les produits :
pa (ω i ) · pb (ω i ) i = 0, 1, . . . , n − 1 .
Ensuite, on calcule la FFT inverse pour obtenir les coefficients ci du polynôme pc . On obtient
ainsi une méthode pour multiplier deux nombres avec une complexité asymptotique meilleure
que celle de la multiplication de Karatsuba (exemple 52). Le calcul exact de la complexité
depend d’un certain nombre de choix de conception. Si l’on représente les nombres complexes
avec un couple de flottants en double précision, il faut procéder à une analyse des erreurs. En
effet, suite aux erreurs d’arrondi, le résultat du calcul peut être un nombre non-entier et il
faut donc s’assurer qu’en arrondissant ce nombre à un entier on obtient le résultat attendu.
Une approche alternative consiste à remplacer le cercle unitaire complexe avec un corps fini
suffisamment grand ; dans ce cas on parle aussi de number theoretic transform. On obtient
ainsi une complexité asymptotique O(n · log(n) · log(log(n))).
Chapitre 17
Algorithmes probabilistes
On peut introduire une composante aléatoire dans la conception et/ou l’analyse d’un al-
gorithme. On distingue :
Algorithme probabiliste On considère un algorithme qui à certains moments du calcul
joue à pile ou face pour déterminer son prochain état. On définit une v.a.d. X qui
associe à chaque exécution possible (sur une entrée fixée) son coût d’exécution et on
cherche à calculer son espérance E[X].
Analyse en moyenne On fait une hypothèse sur la distribution des entrées. On définit
une v.a.d. X qui associe à chaque entrée son coût d’exécution et on cherche à calculer
son espérance E[X].
Dans ce chapitre, on présente 3 exemples majeurs de l’approche probabiliste : le tri ra-
pide, un test de primalité et un test d’identité de polynômes. Dans les chapitres suivants,
on présentera d’autres exemples de conception et/ou d’analyse probabiliste en relation avec
certaines structures de données (arbres binaires de recherche, listes à enjambement, tables de
hachage).
1. Cette utilisation de la fonction rand est adaptée aux simulations, les tests de programmes,. . . mais elle
n’est pas du tout recommandée pour les applications cryptographiques.
123
124 Algorithmes probabilistes
Dans les analyses, on fera l’hypothèse que les résultats d’une suite d’invocations du
générateur sont indépendants. Donc la probabilité d’obtenir une suite w ∈ {0, 1}∗ est 2−|w| .
Fixons un algorithme A (ou un programme) et une entrée i de l’algorithme. Une exécution
de A(i) est une suite d’états traversées par l’algorithme à partir de la configuration ini-
tiale (voir chapitre 12) Si l’exécution termine alors la suite est finie sinon elle est infinie
(dénombrable). Dans les algorithmes déterministes l’exécution est unique, mais dans les al-
gorithmes probabilistes on peut avoir plusieurs exécutions (pour la même entrée).
Probabilité de terminaison
Soit Ω l’ensemble des exécutions finies de A(i). Pour tout ω ∈ Ω on définit :
Par extension, on dit qu’un algorithme A termine avec probabilité 1 si pour toute entrée il
termine avec probabilité 1. La fonction p n’est pas forcement une probabilité, mais au moins
on a la propriété suivante.
Proposition 12
Σω∈Ω p(ω) ≤ 1 .
Σw∈R 2−|w| ≤ 1 .
Rn = {w ∈ R | |w| ≤ n} .
Comme Rn ⊆ Rn+1 :
Σw∈Rn 2−|w| ≤ Σw∈Rn+1 2−|w| ≤ 1 .
Algorithmes probabilistes 125
Donc :
Σw∈R 2−|w| = limn→+∞ Σw∈Rn 2−|w| ≤ 1 .
2
Si Σω∈Ω p(ω) = 1 alors on peut définir un espace de probabilité discret :
(Ω, 2Ω , P )
avec pour A ⊆ Ω, P (A) = Σω∈A p(ω). Remarquons qu’un algorithme qui termine avec pro-
babilité 1 n’est pas un algorithme dont toutes les exécutions sont finies (mais les exécutions
infinies ont probabilité 0).
C(ω) = |ω| ,
Σω∈Ω 2−r(ω) .
Exemples
On applique les notions présentées à une série d’exemples académiques avant de passer à
des exemples plus intéressants.
(a) 1 + x ≤ ex ,
(b) Σi=1,...,n 21i = 1 − 1
2n .
Σn=1,...,∞ pn
≤ 12 + √1e · (Σi=2,...,∞ 21i )
√
1 √1 1 e+1
= 2 + e
· 2 = √
2 e
≈ 0, 8 < 1 .
Analyse Pour terminer on doit tirer m fois 1. Il s’agit de la distribution binomiale négative.
Donc :
A noter cette fonction dépend de l’entrée et que son coût est exponentiel dans le nombre
de bits nécessaires à représenter l’entrée m.
On considère maintenant le temps moyen de calcul en comptant les itérations des 2 boucles
while et en faisant abstraction du fait que les entiers représentables par le type int de C sont
bornés par 2,147,483,647. Soient C1 et C2 les v.a.d. coût qui correspondent à la première
et à la deuxième boucle while. On a :
P (C1 = n) = 21n
E[C1 ] =2
E[C2 |C1 = n] = 2n .
128 Algorithmes probabilistes
Exercice 19 Considérez la fonction C suivante qui effectue une recherche ‘aléatoire’ d’un
élément dans un tableau.
1 int rs ( int x , int n , int t [ n ]){
2 short check [ n ];
3 int i ;
4 for ( i =0; i < n ; i ++){
5 check [ i ]=0;}
6 int count =0;
7 while ( count < n ){
8 int i =( rand ()% n );
9 if ( t [ i ]== x ){
10 return i ;}
11 if (! check [ i ]){
12 check [ i ]=1;
13 count ++;}}
14 return -1;}
Calculez (de façon exacte ou approchée) le nombre moyen d’appels à la fonction rand dans
les cas suivants :
1. Le tableau contient l’élément cherché 1 fois.
2. Le tableau contient l’élément cherché k fois.
3. Le tableau ne contient pas l’élément cherché.
Algorithme de partition
Le tri rapide est basé sur une fonction de partition qui prend en entrée un ensemble fini
de valeurs X et une valeur pivot v et génère l’ensemble X1 des valeurs dans X strictement
inférieurs à v et l’ensemble X2 des valeurs dans X supérieurs ou égales à v. Supposons que X
contienne n valeurs. Si X est représenté par une liste alors il est clair que l’on peut produire
les deux listes qui représentent les ensembles X1 et X2 en O(n). Si X est représenté par un
tableau a alors il est remarquable que l’on peut générer X1 et X2 en temps O(n) et sans
effectuer d’allocation de mémoire (en anglais, on dit aussi que l’algorithme travaille in place).
Supposons que les éléments de l’ensemble à partitionner sont mémorisés dans les cellules
d’indice compris entre i et j avec i < j On itère :
2. Cet algorithme est dans une top 10 d’algorithmes du XX siècle (voir https://www.siam.org/pdf/news/
637.pdf).
Algorithmes probabilistes 129
Algorithme de tri
On considère maintenant l’application de l’algorithme de partition au problème du tri. On
suppose que les données à trier sont stockées dans un tableau a dans les positions comprises
entre min et max et on prend a[max] comme pivot. Si min = max on a rien à faire ! Sinon :
— soit k le point de partition par rapport au pivot,
— si k<max on échange a[k] avec a[max] ; on met donc le pivot au point de partition,
— si nécessaire, on calcule récursivement qsort(min,k-1) et qsort(k+1,max).
à partir de gauche. Comme souvent dans les algorithmes de tri, on considère que le coût
est proportionnel au nombre de comparaisons et on s’attache donc à compter le nombre de
comparaisons effectuées en moyenne par l’algorithme. Ce nombre dépend du choix aléatoire
des pivots. On représente un calcul par la suite des pivots choisis. Soit Ω l’ensemble de ces
suites. On définit une v.a.d. X qui associe à chaque suite le nombre de comparaisons effectuées
par le tri rapide. Le but est de calculer l’espérance E[X].
Remarque 18 Soient i, j ∈ {1, . . . , n} avec i < j deux éléments à trier. Dans toute exécution,
i et j sont comparés au plus un fois. En effet, l’algorithme compare un pivot aux autres
éléments d’une partition. Donc pour comparer i et j il faut que l’un des deux soit un pivot et
l’autre se trouve dans la même partition. Par ailleurs, dans la suite du calcul le pivot ne sera
plus comparé à un autre élément (à la fin de la partition le pivot se trouve à la bonne place).
On observe :
X = Σ1≤i<j≤n Xi,j .
Et par linéarité :
E[X] = Σ1≤i<j≤n E[Xi,j ] .
Il reste donc à calculer E[Xi,j ].
Une première remarque est que P (i, j, n) satisfait une relation de récurrence.
P (1, 2, 2) = 1
P (i, j, n) = n2 + 1
n · ( Σk=1,...,(i−1) P (i − k, j − k, n − k) +
Σk=(j+1),...,n P (i, j, k − 1) ) .
Preuve. Pour comparer i à j, soit on prend le pivot dans {i, j} soit on le prend avant i ou
après j. 2
Une deuxième remarque (assez surprenante) est que P (i, j, n) ne dépend pas de n.
Proposition 14
2
P (i, j, n) = .
(j − i + 1)
Algorithmes probabilistes 131
2
Preuve. Par récurrence sur n. Pour n = 2 on a bien P (1, 2, 2) = 2−1+1 = 1. Plus en général :
P (i, i + 1, n) = 1. Pour n + 1 > 2 on a :
2 1
P (i, j, n + 1) = n+1 + n+1 ( Σk=1,...,(i−1) P (i − k, j − k, n + 1 − k) +
Σk=(j+1),...,n+1 P (i, j, k − 1) )
2 1 2
= n+1 + n+1 ( Σk=1,...,(i−1) j−i+1 +
2
Σk=(j+1),...,n+1 j−i+1 )
2 1 2(n−j+i)
= n+1 + n+1 j−i+1
2
= j−i+1 .
2
Preuve. On calcule :
2
E[X] = Σi=1,...,(n−1) Σj=i+1,...,n (j−i+1)
1
= 2 · (Σi=1,...,n−1 (Σk=1,...,(n−i) (k+1) ))
1
≤ 2 · (Σi=1,...,n−1 (Σk=1,...,n k )) .
E[X] ≤ 2 · (n − 1)(log n + 1)
probabilistes (dits de Montecarlo) s’ils terminent peuvent fournir des réponses incorrectes. On
cherche alors à borner la probabilité d’une réponse incorrecte et dans certains cas favorables
on peut montrer qu’en itérant l’algorithme un certain nombre de fois sur la même entrée
on obtient une réponse incorrecte avec une probabilité négligeable en pratique (par exemple
une probabilité d’erreur inférieure à 2−100 ). Parmi les algorithmes qui tombent dans cette
catégorie, on étudie un test de primalité dans cette section et un test pour l’identité de deux
polynômes dans la suivante.
Rappels d’arithmétique
Pour n ≥ 2, on pose :
Zn = {0, 1, . . . , n − 1},
Z∗n = {a ∈ Zn | pgcd (a, n) = 1} .
A = {a0 , a1 , . . . , ak−1 } ,
où il est entendu que les exposants sont modulo n. Si k = (n − 1) on a terminé. Sinon on,
va montrer que (n − 1) est un multiple de k, disons (n − 1) = k · m, et par les propriétés de
l’exposant on a :
an−1 = (ak )m = 1m = 1 .
On montre d’abord que ]A = k. En effet, si 0 ≤ i < j ≤ (k − 1) alors ai 6= aj . Autrement,
a(j−i) = 1 et (j − i) < k.
Si k < (n − 1) alors on peut trouver b1 ∈ (Z∗n \A). On considère l’ensemble :
A1 = {a0 b1 , a1 b1 , . . . , ak−1 b1 } .
Test de Fermat
La proposition 16 suggère une méthode pour tester la primalité d’un nombre n ≥ 2. Choisir
a ∈ {2, . . . , n − 1} et vérifier :
(a(n−1) ≡ 1) mod n . (17.1)
Pour calculer l’exposant modulaire on utilise la méthode du carré itéré présentée dans le
chapitre 13.
Algorithmes probabilistes 133
Soit n un nombre qui n’est pas premier. Le problème est maintenant d’estimer la proba-
bilité qu’en choisissant un nombre a ∈ {2, . . . , n − 1} on obtient :
(a(n−1) 6≡ 1) mod n . (17.2)
On appelle un tel nombre a un témoin de la non-primalité de n.
Proposition 17 Soient n ≥ 2 et a ∈ {2, . . . , n − 1}
1. Si pgcd (a, n) 6= 1 alors (a(n−1) 6≡ 1) mod n.
2. Si n n’est pas premier et si n admet un témoin a ∈ Z∗n alors au moins la moitié des
éléments dans {2, . . . , n − 1} sont des témoins de la non-primalité de n.
Preuve. (1) On remarque : (i) si (an−1 ≡ 1) mod n alors pgcd (an−1 , n) = 1 et (ii) si d
divise a et n alors d divise an−1 et n.
(2) D’abord on observe que si a ∈ Z∗n est un témoin et d ∈ Z∗n ne l’est pas alors (ad) ∈ Z∗n
est un témoin. En effet, on a :
((ad)n−1 ≡ an−1 dn−1 ≡ an−1 6≡ 1) mod n .
Ensuite, on montre que si a ∈ Z∗n est un témoin et d, d0 ∈ Z∗n ne le sont pas alors ad et ad0
sont deux témoins différents. Supposons 1 ≤ d < d0 < n. Alors (ad ≡ ad0 ) mod n implique
∃ c a(d0 − d) = cn. Comme 1 ≤ (d0 − d) < n, a doit contenir un facteur de n ce qui contredit
a ∈ (Zn )∗ . Il suit que si a ∈ (Zn )∗ est un témoin alors dans (Zn )∗ il y a au moins autant de
témoins que de non-témoins. Par ailleurs, par (1), tous les éléments dans Zn \(Zn )∗ sont des
témoins. 2
Par contre, l’évaluation d’un polynôme sur un point demande un nombre de multiplications
qui est linéaire dans la taille du polynôme. La stratégie pour déterminer si un polynôme
est zéro partout consiste donc à l’évaluer sur un certain nombre de points choisis de façon
aléatoire. On a donc besoin d’estimer la probabilité de tomber sur un zéro du polynôme. Le
point de départ est un résultat standard sur les racines d’un polynômes.
Preuve. On peut utiliser la proposition 9 qui passe par les matrices de Vandermonde. On
présente ici une preuve alternative par récurrence sur d. Si d = 0 alors p(x) est constant et
si p(x) 6= 0 alors il a 0 racines. Si d > 0 et p(a) = 0 alors par la propriété de la division sur
les polynômes ils existent uniques p0 (x) et r(x) tels que p(x) = p0 (x)(x − a) + r, le degré de
p0 (x) est d − 1 et le degré de r est 0. De plus on doit avoir : p(a) = r = 0. Par hypothèse de
récurrence, p0 (x) a au plus d − 1 racines et donc p(x) a au plus d racines. 2
Corollaire 1 Soit p(x) 6= 0 un polynôme avec une indéterminée x et degré d sur un corps
F . Soit F 0 ⊆ F tel que ]F 0 = f . Alors :
d
P (a ← F 0 : p(a) = 0) ≤ .
f
Par exemple, si l’on prend f = 2d la probabilité de tomber sur une racine du polynôme
est au plus 1/2. Si l’on répète le test 100 fois en choisissant des éléments a1 , . . . , a100 de F 0 de
façon indépendante alors si p(ai ) = 0 pour i = 1, . . . , n on peut affirmer que p = 0 avec une
probabilité d’erreur bornée par 2−100 . Ce résultat se généralise à des polynômes en plusieurs
indéterminées (un résultat connu aussi comme lemme de Schwartz-Zippel).
Preuve. Par récurrence sur m. Pour m = 1 on applique le corollaire 1. Pour m > 1 on peut
réécrire p(x1 , . . . , xm ) en factorisant la variable x1 :
p(x1 , . . . , xm ) = Σi=0...,d xi1 · pi (x2 , . . . , xm ) . (17.3)
Comme p 6= 0 il existe j tel que pj 6= 0. Tirons a1 , a2 , . . . , am avec probabilité uniforme. On
pose :
p0 (x1 ) = p(x1 , a2 , . . . , am ) .
Si p(a1 , . . . , am ) = 0 alors on a deux situations possibles :
1. pi (a2 , . . . , am ) = 0 pour i = 0, . . . , d.
2. ∃ i pi (a2 , . . . , am ) 6= 0 et p0 (a1 ) = 0 (avec p0 (x1 ) 6= 0 car pi (a2 , . . . , am ) 6= 0).
La probabilité de la première situation est bornée par :
d · (m − 1)
P (a2 , . . . , am ← F 0 : pj (a2 , . . . , am ) = 0) ≤ ,
f
et la probabilité de la deuxième est bornée par :
d
P (a1 ← F 0 : p0 (a1 ) = 0) ≤ ,
f
et on conclut en observant :
d · (m − 1) d d·m
+ = .
f f f
2
Exemple 59 Les polynômes ont une propriété remarquable : s’ils sont différents alors ils sont
différents presque partout. Cette propriété est souvent utilisée pour amplifier les différences
entre deux structures discrètes (par exemple, dans le cadre des codes correcteurs d’erreurs). On
donne un petit exemple de cette application. Considérez les expressions booléennes suivantes :
A = (¬x1 · x2 + x1 · ¬x2 ) · x3 + x1 · x2 , B = (¬x1 · x2 + x1 ) · x3 .
Dans une expression booléenne, les variables varient sur l’ensemble 2 = {0, 1}. Les symboles
¬, · et + indiquent la négation, la conjonction et la disjonction logique, respectivement. On
aimerait savoir si pour toute affectation de valeurs booléennes aux variables, A et B produisent
toujours le même résultat.
Si on échantillonne x1 , x2 , x3 dans 2 de façon uniforme, la probabilité de distinguer ces
expressions est 1/23 (il y a une seule affectation qui distingue les deux expressions). Il se trouve
qu’on peut voir ces expressions comme des polynômes. Pour ce faire, on replace la négation
¬x par (1 − x) et la conjonction et la disjonction par le produit et la somme, respectivement.
On obtient ainsi :
pA = ((1 − x1 ) · x2 + x1 · (1 − x2 )) · x3 + x1 · x2 , pB = ((1 − x1 ) · x2 + x1 ) · x3 .
Les expressions en question sont dans une classe d’expressions pour laquelle on peut montrer
que deux expressions sont équivalentes ssi elles induisent le même polynôme. Ainsi, pour
distinguer A et B, on peut échantillonner x1 , x2 , x3 dans Z7 = {0, 1, . . . , 6} et dans ce cas la
probabilité de distinguer pA de pB est 216/343 !
Remarque 19 C’est un problème ouvert important de savoir s’il existe un algorithme déterministe
polynomial en temps pour décider de l’identité de deux polynômes à plusieurs variables.
136 Algorithmes probabilistes
Chapitre 18
Soit A un ensemble fini d’éléments que l’on peut comparer avec un ordre total. On cherche
une façon de représenter A qui nous permet d’effectuer (au moins) les opérations suivantes
de façon efficace :
— insertion d’un élément dans A,
— test d’appartenance,
— élimination d’un élément de A.
Si l’on représente un ensemble avec n éléments comme une liste (ordonnée ou non-
ordonnée) ces opérations coûtent O(n). On va étudier une représentation basée sur des arbres
binaires de recherche (binary search trees en anglais, ABR en abrégé) qui permet d’effectuer
les opérations en O(h) où h est la hauteur de l’arbre qui représente l’ensemble.
18.1 Opérations
Définition 16 (ABR) Un ABR est un arbre dans le sens de la définition 6 et qui en plus
satisfait la condition suivante : la valeur de chaque noeud est supérieure à la valeur de chaque
noeud dans le sous-arbre gauche et est inférieure à la valeur de chaque noeud dans le sous-
arbre droit.
On fait l’hypothèse que chaque noeud est représenté par une structure avec 3 champs :
val valeur
left pointeur fils gauche
right pointeur fils droit
Un ABR vide nil est typiquement représenté par un pointeur NULL. On étudie un certain
nombre d’opérations dont la programmation est directe si l’on utilise les appels récursifs.
Avec l’exception de l’opération d’impression qui est linéaire dans la taille de l’ABR, toutes
les autres opérations sont linéaires dans la hauteur de l’ABR.
Impression L’impression par ordre croissant d’un ABR correspond à une visite en pro-
fondeur d’abord et de gauche à droite de l’arbre. Récursivement :
— On imprime le sous-arbre gauche.
— On imprime le noeud.
— On imprime le sous-arbre droit.
Insertion Pour insérer un élément x on navigue dans l’ABR jusqu’à trouver :
137
138 Arbres binaires de recherche
Remarque 20 Si T est une liste alors P (T ) est O(n2 ) et si T est un arbre complet alors
P (T ) est O(n log n).
Définition 19 (moyenne somme hauteurs) On dénote par P (n) la moyenne des P (Tπ )
(définition 17) :
1
P (n) = (Σπ∈Sn P (Tπ )) .
n!
Par exemple, pour n = 3 on a 3! = 6 ABR possibles. Parmi ces ABR, il y en a 4 avec
P (T ) = 3 et 2 avec P (T ) = 2. On a donc P (3) = 8/3. Notre objectif est de trouver une borne
supérieure pour la fonction P (n). Si T est un ABR non-vide alors on dénote par Tg et Td
les sous-arbres gauche et droite (qui peuvent être vides). On vérifie aisément la proposition
suivante.
P (T ) = P (Tg ) + P (Td ) + (n − 1) .
La proposition suivante nous donne une façon d’exprimer la fonction P (n) par récurrence.
Proposition 21
0 si n = 1
P (n) = 1
n (Σi=1,...,n (P (i − 1) + P (n − i) + (n − 1))) si n > 1 .
Preuve. Le cas n = 1 est clair. Si n > 1 on argumente comme suit. Si on tire π de façon
uniforme on a pour 1 ≤ i ≤ n :
1
P (π(1) = i) = .
n
A noter que π(1) = i veut dire que i est à la racine de l’arbre généré T . Ensuite l’arbre de
gauche Tg (de droite Td ) résultera d’une permutation de i − 1 éléments (n − i éléments). On
applique la proposition 20. 2
Si on prend f (n) = 2n log n, on satisfait la première condition car f (1) = 0. Par ailleurs, on
a: Z n
Σk=1,...,n−1 f (k) ≤ f (x)dx .
1
x2
R
En utilisant le fait que : x log xdx = 4 (2 log x − 1), on vérifie la deuxième condition (18.1).
On peut donc montrer par récurrence que P (n) ≤ f (n) = 2n log n. Il suit que la hauteur de
chaque noeud est en moyenne O(log n). 2
Chapitre 19
Les listes à enjambements (skip lists) sont une structure de données introduite par W.
Pugh [Pug90] à base de listes doublement chaı̂nées à plusieurs étages. Il s’agit d’une structure
de données ‘probabiliste’ dans le sens que certaines décisions sur la configuration des listes
sont prises de façon probabiliste. On obtient ainsi une mise en oeuvre relativement simple des
opérations standards de recherche, insertion et élimination, tout en assurant une complexité
en temps logarithmique avec une probabilité élevée.
Considérons une liste ordonnée L0 avec n éléments. Pour l’instant on se focalise sur la
recherche d’un élément dans la liste. On sait que la complexité de cette opération est O(n).
Supposons maintenant qu’on ajoute une deuxième liste L1 pour accélérer la recherche. Dans
cette deuxième liste on va mémoriser une fraction des éléments de la liste L0 . L’idée est que
la liste L0 est une ligne locale qui dessert toutes les stations alors que la liste L1 est une
sorte de ligne rapide qui nous permet d’avancer rapidement jusqu’à un certain point où on
peut être obligé d’emprunter la ligne locale L0 . Une configuration ‘optimale’ consiste à mettre
√ √
dans la liste L1 n éléments de la liste L0 qui sont espacés de n. Par exemple, si la liste
L0 contient les éléments 1, 2, . . . , 15, 16, on va insérer dans la liste L1 les éléments 1, 5, 9, 13.
√
Dans la recherche d’un élément on va visiter au plus n noeuds dans la liste L1 et au plus
√ √
n noeuds dans la liste L0 . Donc, dans le pire des cas, on visite 2 · n noeuds et on a une
√
complexité en O( n).
On peut aussi ajouter une ligne ‘TGV’ L2 . Dans ce cas, L1 va contenir n2/3 éléments de
√ √ √
L0 espacés de 3 n et L2 va contenir 3 n éléments de L1 espacés de 3 n. Par exemple, si L0
contient les éléments 1, 2, . . . , 27, alors L1 contient les éléments 1, 4, 7, 10, 13, 16, 19, 22, 25, et
√
L2 contient les éléments 1, 10, 19. La recherche va donc visiter au plus 3 · 3 n noeuds, soit
√
O( 3 n). En général, on peut imaginer de construire la liste Li+1 à partir de la liste Li en
sélectionnant un élément sur deux. Ce processus s’arrête forcement après log 2 (n) étapes et il
permet la recherche d’un élément en temps O(log(n)) d’une façon qui rappelle la recherche
dichotomique ou la recherche dans un arbre binaire ordonné. Par exemple, si L0 contient les
141
142 Listes à enjambements
Recherche Un algorithme de recherche d’un noeud avec valeur x à partir du point d’entrée
` pourrait être le suivant :
1. Si ` = NULL : on rend NULL comme résultat.
2. Sinon, si ` → val = x : on rend ` comme résultat.
3. Sinon, soient `r = (` → right) et `d = (` → down).
3.1 si `r = NULL : ` = `d et on itère.
3.2 Sinon, si x < (`r → val) : ` = `d et on itère.
3.3 Sinon, si x ≥ (`r → val) : ` = `r et on itère.
Élimination Pour éliminer une valeur x de la structure on va effectuer une recherche jusqu’à
trouver (si elle existe) l’occurrence de x dans la liste de niveau le plus élevé (sinon, il n’y a
rien à faire). Pendant cette recherche on maintient un pointeur pred de façon telle que si on
trouve la valeur x dans un noeud n alors pred pointe au prédécesseur de n. Ensuite, on élimine
le noeud correspondant ainsi que tous les noeuds qui contiennent la même valeur jusqu’à la
liste L0 . Pour ce faire, la première fois on utilise le pointeur pred et pour les niveaux inférieurs,
on cherche le prédécesseur du noeud (n → down) à partir du noeud (pred → down). Il suit
de la définition de la fonction d’insertion (à suivre), que le nombre moyen d’arêtes entre
(pred → down) et (n → down) est 2.
Insértion Pour insérer une valeur x dans la structure il faut d’abord effectuer une recherche
pour déterminer l’endroit où x doit être inséré dans la liste L0 (si pendant la recherche on
trouve x, il n’y a rien à faire). Pendant cette recherche on maintient dans une pile P les
Listes à enjambements 143
derniers éléments visités dans chaque liste ; ces éléments sont les prédécesseurs potentiels des
noeuds créés qui vont contenir la valeur x.
Après la phase de recherche, on va créer un noeud n qui contient la valeur x, on extrait le
premier élément de la pile P et on l’utilise pour insérer n dans la liste L0 .
Ensuite on passe à la phase probabiliste. On joue à pile ou face et tant qu’on tire pile on
effectue les opérations suivantes.
— Si la pile P est vide on ajoute un nouveau niveau à la structure avec un noeud qui
contient la valeur non-standard −∞. Ce noeud devient le nouveau point d’entrée de
la structure et il est inséré dans la pile.
— On extrait le premier élément de la pile P et on l’utilise pour insérer un nouveau noeud
qui contient x.
19.3 Analyse
Chaque élément qui a été inséré dans la liste a été ajouté à la liste L0 et ensuite il a
été propagé aux listes supérieurs avec une probabilité fortement décroissante. Soit Xj , pour
j ∈ {1, . . . , n} une v.a.d. qui indique la hauteur atteinte par le j-ème élément de la structure.
On a :
P (Xj ≥ k) ≤ 2−k .
Soit H une v.a.d. qui représente la hauteur de la structure (le nombre de listes). On a :
N = Σi=1,...,8log 2 (n) Xi ,
où Xi est une v.a.d. de Bernoulli. L’espérance de N est E[N ] = 4log 2 (n). La borne de
Chernoff est une inégalité qui s’applique à la somme de v.a.d. de Bernoulli indépendantes.
Intuitivement, la borne dit que la probabilité que la somme s’écarte de la moyenne diminue
de façon exponentielle. Parmi les nombreuses formulations qu’on trouve dans la littérature,
on utilise la suivante :
P (N ≥ cN E[N ]) ≤ e−kE[N ] , (19.1)
où k = cN ln(cN ) − cN + 1. Si l’on prend cN = 3/2 on a k ≈ 0, 1 et donc :
Cette borne implique que si on fait 8log 2 (n) tirages avec probabilité au moins (1 − n−0,4 )
on va remonter jusqu’à la liste sommitale. On peut conclure l’analyse en combinant les deux
bornes.
P ((H < 2log 2 (n)) ∩ (N < 6log 2 (n))) = 1 − P ((H ≥ 2log 2 (n)) ∪ (N ≥ 6log 2 (n)))
≥ 1 − (P (H ≥ 2log 2 (n)) + P (N ≥ 6log 2 (n)))
≥ 1 − ( n1 + n0,4
1
).
En pratique, les listes à enjambements sont compétitives avec les arbres binaires équilibrés
tout en ayant une mise-en-oeuvre plus simple. A noter, qu’il est aussi possible de concevoir des
listes à enjambements déterministes [MPS92] qui garantissent une complexité des opérations
considérées en temps O(log(n)) dans le pire des cas.
Chapitre 20
Tables de hachage
Après les arbres binaires et les listes à enjambements, les tables de hachage (hash tables
en anglais) sont une troisième structure de données qui permet une mise en oeuvre efficace
des opérations de recherche, insertion et élimination sur un ensemble fini d’éléments. Dans
une table d’hachage, la recherche d’un élément commence par un accès direct à un tableau en
utilisant une fonction de hachage et continue avec la visite d’une liste qui peut être représentée
de façon explicite avec des pointeurs (table avec chaı̂nage) ou de façon implicite avec une
fonction de sondage (table avec adressage ouvert).
1
P (h(x) = i) = .
m
145
146 Tables de hachage
Construction
— Soient k1 , k2 ∈ U avec k1 6= k2 .
— On construit un ensemble de fonctions de hachage H tel qu’en tirant avec probabilité
uniforme h ∈ H on a :
P (h(k1 ) = h(k2 )) ≤ 1/m . (20.1)
— D’abord, on cherche p premier tel que ]U ≤ ]Zp . On peut donc voir toute clé comme
un entier modulo p et on prend les fonctions de la forme : 1
— Soit :
F = {f : Zp → Zp | f (x) = (ax + b) mod p, a ∈ (Zp )∗ , b ∈ Zp } .
On a ]F = p(p − 1).
— Soit :
P = {(x, y) ∈ Zp × Zp | x 6= y} .
On a aussi ]P = p(p − 1).
— Soit i : F → P :
(x ≡ y) mod m .
(p + m − 1) (p − 1)
dp/me − 1 ≤ −1= .
m m
Donc si on tire au hasard un élément différent de x, la probabilité d’une collision est
au plus :
p−1 1
= .
m(p − 1) m
En d’autres termes, si on tire au hasard (a, b) ∈ (Zp )∗ × Zp :
1
P (((ak1 + b) mod p ≡ (ak2 + b) mod p) mod m) ≤ .
m
1. On note au passage qu’en prenant les fonctions de la forme x 7→ (ax + b) mod m avec a ∈ Z∗m et b ∈ Zm
on n’obtient pas la propriété attendue (20.1) : si (k1 ≡ k2 ) mod m alors les valeurs hachées coı̈ncident.
148 Tables de hachage
haux : U → (Zm )∗ ,
On a donc :
E[X] ≤ Σi=1,...,∞ i · P (X = i)
= Σi=1,...,∞ i · (P (X ≥ i) − P (X ≥ i + 1))
= Σi=1,...,∞ P (X ≥ i)
≤ Σi=1,...,∞ αi−1
= Σi=0,...,∞ αi
1
= 1−α .
Exemple 61 Dans l’approche avec adressage ouvert, on épargne la mémoire pour les poin-
teurs mais la cardinalité de l’ensemble représenté est bornée par la taille de la table et DE-
LETED dégrade les performances. Avec toutes ces réserves, considérons une situation où
l’adressage ouvert se compare favorablement au chaı̂nage.
Supposons qu’une clé et un pointeur prennent le même espace et que les problèmes associés
aux bornes et aux DELETED ne se posent pas. Si une table de hachage avec chaı̂nage a un
facteur de charge α = 2 on utilise m cellules pour la table et 4m cellules pour les listes. Avec
l’adressage ouvert, on peut donc avoir un facteur de charge α0 = 2/5 et une recherche qui
1
échoue effectue en moyenne 1−2/5 = 5/3 < 2 sondages.
En conclusion, mentionnons 2 variations possibles sur le thème des tables de hachage : les
tables dynamiques et les tables parfaites.
Dans les tables de hachage dynamiques, on prévoit la possibilité d’élargir ou réduire dy-
namiquement la taille de la table de hachage de façon à garder le facteur de charge dans un
certain intervalle. De plus, dans certaines applications on souhaite élargir ou réduire de façon
incrémentale. En d’autres termes, on répartit le travail de gestion des tables dynamiques sur
toutes les opérations de façon à garantir la réactivité du système.
Parfois, on connaı̂t à l’avance les n éléments qui peuvent être dans l’ensemble. On peut
alors allouer une table de taille n et garantir un temps d’accès constant dans le pire des cas
(plutôt qu’en moyenne). On parle dans ce cas de tables de hachage parfaites.
150 Tables de hachage
Chapitre 21
Algorithmes gloutons
Un algorithme glouton (greedy en anglais) est un algorithme qui cherche une solution à
un problème en suivant un critère d’optimum local. En général cette approche peut être vue
comme une heuristique mais dans certains cas elle permet de trouver la solution de façon opti-
male et efficace. En particulier, dans le cadre ‘continu’ de l’optimisation convexe, on sait qu’un
optimum local coı̈ncide toujours avec l’optimum global. Dans un cadre ‘discret’, on présente
deux exemples de cette situation favorable qui concerne la recherche d’une sous-séquence
contiguë maximale et la recherche d’une compression optimale. Le chapitre 24 proposera
aussi deux autres exemples dans le cadre des graphes pondérés.
Remarque 23 Dans la suite on se focalise sur le calcul de si,j et on laisse comme exercice
le problème de calculer le couple (i, j) associé.
On fait l’hypothèse que la séquence est mémorisée dans un tableau ce qui permet d’accéder
chaque élément du tableau en temps constant. Pour calculer le résultat il faut au moins lire
chaque élément de la séquence. Donc on ne peut pas faire mieux que O(n). On va pratiquer
une approche ‘directe’ et une approche ‘diviser pour régner’ avant d’arriver à l’approche
‘gloutonne’.
151
152 Algorithmes gloutons
Approche directe
On calcule :
s1,1 , s2,2 , · · · , sn,n (n longueur 1)
s1,2 , · · · , sn−1,n (n − 1 longueur 2)
··· ···
s1,n (1 longueur n)
et on remarque que :
si,j+1 = si,j + xj+1 .
n(n + 1)
n + (n − 1) + · · · + 2 + 1 = .
2
Exercice 22 Montrez qu’on peut calculer la SCM en utilisant une quantité linéaire de mémoire.
Remarque 24 Soit m = (i + j)/2. Pour calculer SCM (i, j) (où i < j) on calcule :
— v1 = SCM (i, m).
— v2 = SCM (m + 1, j).
— v3 = SCMD(i, m) et v4 = SCMG(m + 1, j).
et on prend :
max {v1 , v2 , v3 + v4 } .
Il en suit que le calcul de SCMD(i, j) est O(j − i). Et de même pour SCMG. On retrouve
la récurrence du tri par fusion (chapitre 15) :
C(n) = 2 · C(n/2) + n .
Approche gloutonne
On abrège :
mi = SCMD(1, i) pour 1 ≤ i ≤ n .
La remarque suivante est attribuée à J. Kadane [Ben84] :
— Le max parmi m1 , . . . , mn nous donne une solution à SCM (1, n). En effet une SCM
entre 1 et n va bien terminer à un i tel que 1 ≤ i ≤ n et la même SCM va être une
solution pour le problème SCMD(1, i).
— On sait déjà qu’on peut calculer mi+1 à partir de mi en temps constant car :
Σi=1,...,m pi · |C(ai )| .
On peut voir b ∈ 2∗ comme un chemin dans un arbre binaire et l’ensemble des codes
{C(a1 ), . . . , C(an )} comme le plus petit arbre binaire qui contient les chemins associés aux
codes.
Dans la représentation à arbre d’un codage préfixe, les codes sont exactement les feuilles
de l’arbre. Le décodage d’un codage préfixe est simple : on lit le code de gauche à droite et
on décode dès qu’on reconnaı̂t le code d’un symbole. Il se trouve que sans perte de généralité
on peut se restreindre aux codage préfixes ! En d’autres termes, on peut toujours trouver un
codage qui est optimal et préfixe.
Questions. (1) Quels codes sont décodables ? (2) Quels codes sont préfixes ? (3) Quelle est la
longueur moyenne du codage d’un symbole ?
154 Algorithmes gloutons
p1 + p2 , p3 , . . . , pm ,
Remarque 25 On reconnaı̂t dans cette construction une stratégie gloutonne : pour résoudre
le problème pour p1 , . . . , pm avec p1 ≤ · · · ≤ pm on va résoudre le problème pour p1 +
p2 , p3 , . . . , pm et ensuite expanser la feuille associée à p1 + p2 .
Preuve. Par récurrence sur m. Les cas m = 1 et m = 2 sont clairs. Si m > 2 on sait par
récurrence que l’arbre Tm−1 pour p1 + p2 , p3 , . . . , pm est optimal et que :
Mise en oeuvre
On considère des arbres où chaque noeud contient la somme des probabilités des feuilles
accessibles depuis le noeud (donc la racine de l’arbre contient la somme des probabilités des
feuilles de l’arbre). Initialement, on a m arbres constitués d’une seule feuille avec probabilités
p1 , . . . , pm . On maintient les arbres dans un min-tas (chapitre 14), ordonnés d’après les valeurs
des racines. A chaque étape, on extrait les deux arbres plus petits t1 et t2 du tas et on y insère
un nouveau arbre obtenu en ajoutant un noeud qui pointe à t1 et t2 et dont la probabilité
est la somme des probabilités de t1 et t2 . On répète cette opération m − 1 fois pour obtenir
l’arbre Tm .
Programmation dynamique
f(x){int r; P; return r}
où l’on suppose que P ne contient pas de return et n’a pas d’effet de bord visible. Le coût est
souvent exponentiel car on recalcule f plusieurs fois sur les mêmes points. Un exemple typique
est la programmation récursive de la fonction de Fibonacci :
1 int fibo ( int n ){
2 int r ;
3 if ( n ==0){
4 r =0;}
5 else {
6 if ( n ==1){
7 r =1;}
8 else {
9 r = fibo (n -2)+ fibo (n -1);}} ;
10 return r ;}
157
158 Programmation dynamique
int f(x){
int r;
r=value(T,x);
if (undefined(r)){
P;
insert(T,x,r);}
return r;}
Chaque point est calculé une fois. La table T contient à la fois la clé (l’entrée) et la
valeur de la fonction qui est récupérée avec la fonction value. Le prédicat undefined nous dit
si la fonction a été déjà calculée sur l’entrée x et à défaut la fonction insert insère la valeur
calculée. Il est possible de remplacer la table de hachage par une autre structure de données.
Par exemple, par un arbre binaire de recherche ou une liste à enjambements.
Définition 22 (lcs) On dénote par lcs(α, β) une plus plus longue sous-séquence commune
(longest common subsequence, en anglais, abrégé en lcs) des mots α et β.
Remarque 27 La lcs n’est pas forcement unique. Par exemple, considérez les mots ABC et
ACB et en général le nombre de lcs peut être exponentiel. Notre but sera juste de calculer
une lcs.
Exemple 62
α = ACCGGT CGAGT GCGCGGAAGCCGGCCGAA
β = GT CGT T CGGAAT GCCGT T GCT CT GT AAA
lcs(α, β) = GT CGT CGGAAGCCGGCCGAA
Programmation dynamique 159
On peut voir la lcs comme une mesure de la similarité de deux séquences (par exemple, deux
séquences d’ADN). Nombreuses variations existent : problème de l’alignement de séquences,
distance d’édition,. . .
On peut utiliser la fonction llcs pour calculer la fonction lcs. En particulier, si llcs a été
pré-calculée, le calcul de lcs est linéaire en max (|α|, |β|).
Proposition 25
lcs(, α) = lcs(α, ) =
· lcs(α, β)
lcs(aα, aβ) = a
lcs(aα, β) si llcs(aα, β) ≥ llcs(α, bβ)
lcs(aα, bβ) =
lcs(α, bβ) autrement.
C(n, 0) = C(0, n) = 1
C(n + 1, m + 1) = C(n, m + 1) + C(n + 1, m) ≥ 2 · C(n, m) .
(3, 3), (3, 2), (2, 3), (1, 3), (2, 2), (3, 1), (2, 1), (1, 2), (1, 1) .
Remarque 28 Le calcul ascendant calcule toutes les cellules du tableau alors que le calcul
descendant avec mémoı̈sation en calcule un sous-ensemble. Le cas le plus favorable pour le
calcul descendant est si α = β et le pire est si α et β n’ont pas de caractères communs. En
général, le calcul descendant avec mémoı̈sation est excellent pour un prototypage efficace alors
que le calcul ascendant est utilisé (si besoin) pour une optimisation plus poussée.
160 Programmation dynamique
avec l’interprétation suivante : toute occurrence du symbole A peut être remplacée par les
symboles A1 , . . . , An . Des sous-classes des grammaires algébriques (par exemple LR(1)) sont
utilisées pour spécifier la syntaxe des langages de programmation et des outils automatiques
(par exemple Yacc) construisent un programme d’analyse syntaxique (le parseur en franglais)
à partir de la grammaire.
On va considérer les grammaires en forme normale de Chomsky (FNC). Toute grammaire
algébrique peut être transformée en une grammaire en FNC équivalente (à quelques détails
près).
A → a ou A → BC
avec A, B, C ∈ N et a ∈ Σ.
Exemple 63 Voici un exemple de grammaire FNC qui décrit les suites de parenthèses ‘bien
formées’.
N = {S, L, R, A} Non-terminaux
Σ = {(, )} Terminaux (ou alphabet)
S → LR Règles
S → SS
S → LA
A → SR
L →(
R →)
Notation Comme dans la section précédente on dénote par |w| la longueur du mot w. On
dénote aussi par w[i, j] le sous-mot de w compris entre les positions i et j (1 ≤ i ≤ j ≤ |w|). On
écrit G(A, i, j) ssi le symbole A de la grammaire G peut générer w[i, j]. Avec cette notation,
le problème à résoudre est équivalent à savoir si G(S, 1, |w|). La proposition suivante nous
donne une stratégie pour calculer G(A, i, j).
Le coût est aussi cubique. L’algorithme est connu comme algorithme CYK en référence aux
noms des concepteurs (Cocke, Younger et Kasami).
Exercice 28 Un graphe dirigé étiqueté avec racine est un tuple (N, A, r, L) où :
— N est l’ensemble (fini) des noeuds,
— r ∈ N est la racine,
— A ⊆ N × N est l’ensemble des arêtes,
3. Pour l’analyse syntaxique des langages de programmation, on utilise des grammaires algébriques parti-
culières (LR(1)) qui admettent un algorithme de reconnaissance en O(n).
162 Programmation dynamique
Association à gauche : d0 d1 d2 + d0 d2 d3
Association à droite : d0 d1 d3 + d1 d2 d3
Pour d0 = 10, d1 = 1, d2 = 10, d3 = 1, associer à gauche coûte 10 fois plus cher que associer
à droite.
Soit opt(i, m), pour i ∈ {1, . . . , n} la valeur maximale qu’on peut emporter en ne dépassant pas
le poids m et en supposant qu’on peut choisir parmi les premiers i objets. Exprimez opt(i, m)
par une récurrence et dérivez un algorithme de programmation dynamique pour résoudre le
problème.
4. Il s’agit d’un cas particulier d’automate fini non-déterministe (AFN) dans lequel chaque état est accep-
teur.
Chapitre 23
Graphes
Les graphes sont des structures omniprésentes en informatique dont les listes et les arbres
sont des cas particuliers. Dans ce chapitre, on introduit les méthodes principales pour représenter
les graphes finis et pour les visiter.
23.1 Représentation
Définition 25 (graphe dirigé) Un graphe dirigé est un couple G = (N, A) où N est l’en-
semble des noeuds et A ⊆ N × N est l’ensemble des arêtes.
0 ≤ m ≤ n2 .
Si m est linéaire en n on dira que le graphe est creux (sparse en anglais) et si m s’approche
de n2 on dira qu’il est dense.
Variantes On trouve dans la littérature une grande variété de définitions dont voici cer-
taines.
— Graphes non-dirigés : dans ce cas les arêtes ne sont pas dirigées. On a donc : 0 ≤ m ≤
n(n−1)
2 .
— Graphes étiquetés : les noeuds ou les arêtes ont des valeurs associés (voir, par exemple,
les tas du chapitre 14, les BDD du chapitre 17 et les ABR du chapitre 18).
— Multi-graphes : plusieurs arêtes entre deux noeuds permises.
— Hyper-graphes : une arête peut connecter plus que 2 noeuds.
163
164 Graphes
— Un circuit est un chemin de longueur positive dont le premier et dernier noeud sont
identiques (pour un graphe non-dirigé il faut préciser qu’on ne peut pas utiliser la
même arête 2 fois).
— Un graphe acyclique est un graphe sans circuits.
— S’il y à un chemin de i à j on dit que i est connecté à j. Dans le cas des graphes dirigés,
on dit que deux noeuds sont fortement connectés si i est connecté à j et j à i.
— La relation de connexité forte est une relation d’équivalence et on appelle composantes
fortement connexes ses classes d’équivalence.
Les arbres sont un cas particulier de graphes. On se place dans le cadre des graphes
non-dirigés.
Proposition 27 Soit G = (N, A) un graphe non-dirigé. Les conditions suivantes sont équiva-
lentes :
— G est connecté et acyclique,
— G est connecté et a n − 1 arêtes,
— il existe un chemin unique qui connecte chaque couple de noeuds.
On peut prendre une de ces 3 conditions comme définition d’arbre. On remarquera que
ces arbres diffèrent des arbres utilisés dans les chapitres 14 et 18. En effet, dans les arbres
dont il est question ici, il n’y a pas de racine, les arêtes sont non-dirigées et non-ordonnées (on
ne distingue pas entre arête gauche et arête droite) et le nombre de noeuds adjacents n’est
pas borné.
Soit G = (N, A) un graphe dirigé avec N = {1, . . . , n}. Les deux représentations princi-
pales qu’on va considérer sont les matrices d’adjacence et les listes d’adjacence.
Matrice d’adjacence Une matrice M n × n de booléens telle que :
Liste d’adjacence Un tableau T de taille n tel que l’entrée T [i] pointe à une liste qui
contient exactement les noeuds j tels que (i, j) ∈ A
On utilisera surtout les listes d’adjacence dont la taille est O(n + m) par opposition au
O(n2 ) d’une matrice d’adjacence ce qui est avantageux dans les graphes creux.
Les deux exercices qui suivent utilisent la représentation par matrice d’adjacence.
Exercice 31 Un puit (sink en anglais) dans un graphe dirigé est un noeud avec degré entrant
n − 1 et degré sortant 0. On suppose que le graphe est représenté par une matrice d’adjacence
M et que le temps d’accès à un élément de la matrice est O(1). Soient i, j ∈ N avec i 6= j.
Graphes 165
Algorithme
W = {r};
while(W 6= ∅){
i = remove(W);
if(!mark[i]){
mark[i] = 1;
∀j if((i, j) ∈ A && !mark[j]){
insert(j, W); }}}
Analysons cet algorithme. Pour l’instant on suppose que W est un multi-ensemble et que
remove enlève un élément du multi-ensemble. En spécialisant la structure W on pourra mettre
en oeuvre différentes stratégie de visite (en largeur, en profondeur,. . .).
— Chaque fois qu’on insère un élément dans W on a une arête (i, j) tel que le noeud i
vient d’être marqué et le noeud j n’est pas marqué.
Le nombre d’insertions est borné par m (nombre d’arêtes) !
— Chaque noeud inséré dans W est accessible depuis la racine. Donc chaque noeud
marqué est accessible depuis la racine.
— Chaque noeud accessible depuis la racine est marqué. En effet soit (r, i1 ), . . . , (ik , j)
un chemin de longueur minimal vers un noeud qui n’est pas marqué par l’algorithme.
Mais alors ik est accessible avec un chemin plus court et il est marqué. Donc j sera
inséré dans W et il sera marqué car l’algorithme termine avec W vide. Contradiction.
L’algorithme de recherche est modifié comme suit (où W est une queue) :
W = {r};
while(W 6= ∅){
i = remove(W);
if(!mark[i]){
mark[i] = 1;
∀j if((i, j) ∈ A && !mark[j]){
if(d[j] == +∞){
d[j] = d[i] + 1; }
insert(j, W); }}}
Propriété L’algorithme est O(n + m) car on examine chaque arête au plus une fois et à la
fin de l’algorithme d[i] est la distance de r à i (+∞ si i n’est pas accessible depuis r). Le fait
qu’on utilise une queue assure qu’un noeud ‘proche’ de r est toujours traité avant un noeud
‘éloigné’ de r.
void dfs(i){
if(!mark[i]){
mark[i] = 1;
∀j if((i, j) ∈ A && !mark[j]){
dfs(j); }}}
Remarque 30 Il est possible d’effectuer une visite en profondeur sans pile et sans récursion.
Il suffit de réserver dans chaque noeud un nombre de bits logarithmique dans le degré du
noeud. Ensuite on utilise une technique d’inversion de pointeurs :
— si on descend en profondeur, on inverse le pointeur pour se souvenir d’où on vient.
— si on remonte, on remet le pointeur à sa place.
Ce type d’algorithme (connu comme algorithme de Schorr-Waite) est utilisé lorsque la
mémoire est précieuse. E.g., dans un ramasse miettes (garbage collector, en anglais).
Définition 26 (tri topologique) Un tri topologique d’un graphe dirigé G = (N, A) avec n
noeuds est une fonction ` : N → {1, . . . , n} telle que :
Un tri topologique est une façon de linéariser l’ordre partiel induit par les arêtes. La
linéarisation :
— est unique ssi le graphe est une liste,
— existe ssi le graphe est acyclique.
Pour calculer un tri topologique d’un graphe acyclique il suffit d’enrichir la version récursive
de la fonction dfs comme suit.
— On ajoute un tableau ` et un compteur count qui est initialisé à n.
— La fonction dfs est modifiée pour qu’avant le retour d’un appel dfs(i) on enregistre dans
`[i] la position du noeud i dans le tri en on décrémente count (si au lieu de la récursion
on utilise une pile, la même idée s’applique).
void dfs(i){
if(!mark[i]){
mark[i] = 1;
∀j if((i, j) ∈ A && !mark[j]){
dfs(j); }
`[i] = count;
count − −; }}
— Comme le graphe peut être disconnecté, pour avoir un tri complet il faut dans le pire
des cas appeler dfs sur tous les noeuds.
— Si l’on ne sait pas à l’avance si le graphe est acyclique on peut quand même calculer
le tableau ` et ensuite vérifier la condition :
Elle sera satisfaite ssi le graphe est acyclique. On a donc un algorithme efficace pour
savoir si un graphe est acyclique.
Les exercices suivants explorent des notions classiques de théorie des graphes.
Exercice 33 Soit G = (N, E) un graphe non-dirigé. G est k-coloriable s’il y a une fonction
c : N → {1, . . . , k} telle que si les noeuds i et j sont adjacents alors c(i) 6= c(j). Il est facile de
décider si un graphe est 1-coloriable et difficile (NP-complet) de décider s’il est k-coloriable
pour k ≥ 3. Programmez une fonction (efficace !) qui décide si un graphe est 2-coloriable.
Exercice 34 Un circuit simple dans un graphe non-dirigé est un chemin qui revient au point
de départ sans jamais passer deux fois par la même arête. Un circuit Eulerien est un circuit
simple qui passe par chaque arête du graphe.
168 Graphes
— Montrez qu’un graphe non-dirigé connecté a un circuit Eulerien ssi chaque noeud a
degré pair.
— Programmez un algorithme qui construit un circuit Eulerien pour un graphe connecté
G avec noeuds de degré pair comme suit.
1. Il construit au hasard un circuit simple γ.
2. Tant que γ n’est pas un circuit Eulerien :
(a) Il cherche un noeud i dans γ avec une arête {i, j} pas encore dans γ.
(b) Il construit un circuit simple γ 0 à partir de {i, j} avec les arêtes qui ne sont pas
dans γ.
(c) Il combine γ et γ 0 pour obtenir un nouveau circuit simple γ.
Chapitre 24
Graphes pondérés
Soit G = (N, A) un graphe non-dirigé G = (N, A) connecté et où les arêtes ont un poids
non-négatif :
w : A → R+ .
Définition 27 (ARM) Un arbre de recouvrement minimum (ARM) pour un graphe G est
un arbre qui est un sous-graphe de G qui contient tous les noeuds de G et dont la somme des
poids des arêtes est minimum.
Définition 28 (PCC) Un arbre des plus courts chemins dans un graphe G depuis un noeud
désigné comme source (PCC) est un arbre qui est un sous-graphe de G qui contient tous les
noeuds de G et dont les chemins du noeud source aux autres noeuds sont les plus courts.
Il n’y a pas de perte de généralité à supposer que la solution aux problèmes ARM et PCC
sont des arbres. En effet si on a un graphe qui est une solution optimale on peut toujours
élaguer certaines arêtes jusqu’à obtenir un arbre.
Notez aussi que l’ARM et le PCC peuvent différer. Par exemple, considérez le graphe :
2 2 3
n1 − n2 − n3 n1 − n3 .
Alors, l’ARM est {{n1 , n2 }, {n2 , n3 }} mais le PCC depuis 1 est {{n1 , n2 }, {n1 , n3 }}.
Dans ce chapitre, on va introduire des algorithmes efficaces (quasi-linéaires) pour résoudre
ces problèmes qui utilisent une stratégie gloutonne (chapitre 21) et la structure de données
tas (chapitre 14).
On itère n − 1 fois
— Parmi les arêtes {i, j} avec i ∈ N1 et j ∈ N2 , soit {i0 , j0 } une qui minimise :
w({i, j}) .
— On pose :
N1 = N1 ∪ {j0 }, N2 = N2 \{j0 }, T = T ∪ {{i0 , j0 }} .
169
170 Graphes pondérés
Argument Voici l’argument pour prouver que la stratégie gloutonne calcule bien l’ARM.
— D’abord on remarque que si on a un arbre et on ajoute une arête on a un circuit et si
on enlève une arête quelconque du circuit on a à nouveau un arbre.
— L’algorithme de Prim construit un arbre en ajoutant n − 1 arêtes, disons a1 , . . . , an−1 .
— Soit ai la première arête qui est incompatible avec un ARM. Disons que ai connecte
les noeuds j ∈ N1 et k ∈ N2 .
— Soit T un ARM qui contient les arêtes a1 , . . . , ai−1 .
— On obtient une contradiction en montrant que T peut être transformé en un ARM qui
contient les arêtes a1 , . . . , ai .
— Ajoutons l’arête ai à T . On a donc un circuit.
— Dans le circuit il doit y avoir au moins une autre arête a qui connecte un noeud dans
N1 avec un noeud dans N2 .
— Par définition de l’algorithme de Prim, le poids de ai est inférieur ou égal au poids de
a.
— Donc si on enlève l’arête a on obtient à nouveau un arbre et même un ARM. L’arête
ai n’est donc pas la première qui pose problème. Contradiction.
On itère n − 1 fois
— Parmi les arêtes {i, j} avec i ∈ N1 et j ∈ N2 , soit {i0 , j0 } une qui minimise :
— On pose :
N1 = N1 ∪ {j0 }, N2 = N2 \{j0 },
T = T ∪ {{i0 , j0 }}, L(j0 ) = L(i0 ) + w({i0 , j0 }) .
Argument Voici l’argument pour prouver que la stratégie gloutonne calcule bien l’arbre
PCC.
— Chaque fois qu’on calcule L(j0 ) = L(i0 ) + w({i0 , j0 }), L(j0 ) est bien le poids du plus
court chemin de la racine à j0 .
— En effet un plus court chemin γ de la racine à j0 doit comprendre une arête qui connecte
un noeud k ∈ N1 avec un noeud k 0 ∈ N2 .
— On a :
w(γ) ≥ L(k) + w({k, k 0 }) (par hyp. de récurrence)
≥ L(i0 ) + w({i0 , j0 }) (par construction)
Remarque 31 La visite en largeur étudiée dans la section 23.2 nous donne déjà les plus
courts chemins quand le poids de chaque arête est 1. On pourrait imaginer la transformation
Graphes pondérés 171
suivante d’un graphe pondéré avec des nombres naturels en un graphe ordinaire : on trans-
forme une arête de poids n en n arêtes de poids 1 en introduisant des noeuds intermédiaires.
Le problème avec cette transformation est qu’elle est exponentielle dans le nombre de bits
nécessaires à représenter les poids.
On peut voir un graphe comme un réseau de transport et les pondérations des arêtes
comme une mesure de la capacité de transport de chaque arête. Si on fixe un noeud ‘source’
et un noeud ‘destination’ une question naturelle est celle de maximiser la quantité que l’on
peut transporter de l’origine à la destination. En termes plus techniques, on considère le
problème de maximiser le flot (définition à suivre) dans un graphe dont les arêtes ont des
capacités bornées (problème MaxFlot).
Il se trouve que ce problème admet un problème dual qui consiste à rechercher une coupe
minimale du graphe. Cette notion de coupe minimale est aussi facile à motiver. Par exemple,
si toutes les capacités des arêtes sont identiques, une coupe minimale du graphe consiste à
déterminer le nombre minimum d’arêtes qu’il faut couper pour disconnecter le noeud source
du noeud destination. On a ici un premier exemple de dualité en optimisation combinatoire.
Typiquement cette dualité prend la forme suivante : un problème de maximisation admet un
problème dual de minimisation tel que les solutions des deux problèmes, si elles existent, alors
elles coı̈ncident. En particulier, dans ce chapitre on montrera que le flot maximum coı̈ncide
avec la coupe minimale [FF56].
Ce résultat de dualité couplé avec la notion de chemin augmentant est la base pour la
conception d’algorithmes efficaces (polynomiaux) pour le problème MaxFlot. Le problème
MaxFlot est aussi intéressant pour d’autres raisons.
— Le problème peut être formalisé comme un problème de programmation linéaire (cha-
pitre 26) à savoir un problème de maximisation d’une fonction linéaire sujette à des
contraintes linéaires.
— Quand les capacités sont des entiers, le problème MaxFlot peut aussi être vu comme
un problème de programmation entière, à savoir un problème de programmation linéaire
où en plus on demande à que les solutions soient des entiers. Dans ce contexte, le
problème MaxFlot a la propriété remarquable que le maximum du problème de pro-
grammation linéaire coı̈ncide avec le maximum du problème de programmation entière.
Une capacité est une fonction. Par extension, on parlera aussi de capacité d’une arête et
173
174 Flot maximum et coupe minimale
dans ce cas il s’agira d’un nombre réél positif. Si on prend comme ensemble des arêtes :
on obtient un graphe dirigé et pondéré (voir chapitre 24). 1 Par exemple, si une arête modélise
un tuyau alors la capacité peut correspondre au nombre de litres par second qui peuvent
transiter dans le tuyau. Pour d’autres exemples, on peut s’inspirer des réseaux électriques ou
routiers.
On distingue deux noeuds différents s (source) et d (destination) et on s’intéresse à la
question de trouver le ‘flot’ maximal de s à d que le réseau peut supporter.
Définition 30 (flot) Un flot est une fonction f : N 2 → R qui satisfait les 3 conditions
suivantes : 2
symétrie ∀i, j ∈ N (f (i, j) = −f (j, i)).
conservation ∀i ∈ N \{s, d} Σj∈N f (i, j) = 0.
capacité ∀i, j ∈ N (f (i, j) ≤ c(i, j)).
L’intuition est que f (i, j) décrit la quantité de flot net qui peut aller de i à j. La première
condition exprime le fait que le flot de i à j doit être l’opposé du flot de j à i. Cette condition
implique qu’on a toujours f (i, i) = 0. La deuxième condition est un principe de conservation
du flot : dans tous les noeuds sauf s et d le flot ‘entrant’ doit être égal au flot ‘sortant’. Enfin
la troisième condition impose un flot compatible avec la capacité de l’arête. En particulier :
(i) si c(i, j) = c(j, i) = 0 alors on doit avoir f (i, j) = f (j, i) = 0 et (ii) si f (i, j) < 0 alors
|f (i, j)| ≤ c(i, j) (le flot négatif est aussi borné).
Exemple 64 Supposons un graphe avec deux noeuds et les capacités (non nulles) suivantes :
c(s, d) = 4 et c(d, s) = 2. Alors tout flot doit satisfaire f (d, d) = f (s, s) = 0. Par ailleurs, si on
pose f (s, d) = 2 alors on doit avoir f (d, s) = −2 ce qui est compatible avec les capacités. On
peut remarquer qu’il existe plusieurs façons de ‘réaliser’ concrètement ce flot net. A savoir,
s envoie x à d pour 2 ≤ x ≤ 4 et d envoie (x − 2) à s.
Définition 31 (coupe) Une coupe est une partition de l’ensemble de noeuds N en deux
ensembles A et B tels que s ∈ A et d ∈ B.
Définition 32 (capacité d’une coupe) La capacité c(A, B) d’une coupe (A, B) est :
De façon similaire, si f est un flot on pose f (A, B) = Σi∈A,j∈B f (i, j). Il se trouve que
cette quantité depend du flot mais pas de la coupe.
D’autre part, f (A, B) = f (A0 , B) + f ({i}, B). Par symétrie, f (A0 , {i}) = −f ({i}, A0 ) et par
conservation, f ({i}, A0 ) + f ({i}, B) = 0. Donc f (A0 , {i}) = f ({i}, B) et on peut conclure que
|f | = f (A, B).
(2) Par la condition sur la capacité. 2
On peut donc définir la valeur |f | d’un flot comme le flot f (A, B) par rapport à une coupe
(A, B) quelconque.
|f | = f (A, B) (A, B) coupe (25.1)
Notez que le graphe Gf peut avoir une arête que le graphe initial G n’a pas. Par exemple,
on pourrait avoir c(i, j) = 3, f (i, j) = 1 et c(j, i) = 0. Dans ce cas, r(i, j) = (3 − 1) = 2 et
r(j, i) = (0 − (−1)) = 1.
Définition 36 (obstruction) L’obstruction d’un chemin augmentant est la plus petite ca-
pacité qu’on trouve sur le chemin.
Proposition 30 Soit f un flot pour le graphe G. Alors les propriétés suivantes sont équivalentes.
Preuve. (1) ⇒ (2) Par la proposition 28, on sait que si f 0 est un flot alors :
|f 0 | ≤ c(A, B) = |f | .
Le flot f est donc maximum. Notez aussi que la coupe (A, B) doit être minimum car pour
toute coupe (A0 , B 0 ) on a :
c(A, B) = |f | ≤ c(A0 , B 0 ) .
(2) ⇒ (3) Par contradiction, on suppose disposer d’un chemin augmentant dans Gf . Soit
d > 0 la capacité plus petite dans ce chemin. On peut alors construire un flot f 0 dans Gf en
posant :
d si (i, j) est dans le chemin
0
f (i, j) = −d si (j, i) est dans le chemin
0 autrement
Par la proposition 29, on dérive que f + f 0 est un flot dans G et |f + f 0 | = |f | + d > |f |. Donc
f n’est pas un flot maximum.
(3) ⇒ (1) S’il n’y a pas de chemin augmentant dans Gf alors on peut construire une
coupe (A, B) où A est l’ensemble des noeuds accessibles depuis s et B = N \A. Dans cette
coupe il n’y a pas d’arête de A dans B ce qui veut dire que pour i ∈ A et j ∈ B on a
r(i, j) = c(i, j) − f (i, j) = 0. Donc c(i, j) = f (i, j) et f (A, B) = c(A, B). 2
La proposition 30 implique que si les capacités sont des entiers alors la valeur du flot
maximum est un entier car il est égal à la capacité d’une coupe minimale. La proposition 30
est aussi la base pour la conception d’un algorithme qui calcule le flot maximum en itérant
la construction du graphe résiduel et la recherche d’un chemin augmentant.
Cet algorithme est correct et efficace (polynomial) à condition d’éviter certains écueils.
Clairement on dispose d’algorithmes efficaces pour calculer un chemin augmentant. Une
première stratégie pourrait donc consister à démarrer avec un flot nul est à itérer la re-
cherche d’un chemin augmentant jusqu’à ce qu’il n’y en ait plus. Si les capacités sont des
entiers cette stratégie termine avec le flot maximum. Cependant on peut trouver des suites de
chemins augmentants dont la longueur est exponentielle dans le nombre de bits nécessaires à
représenter les capacités.
Flot maximum et coupe minimale 177
Si les capacités sont des nombres irrationnels, on peut construire un exemple encore plus
patologique. Dans cet exemple on produit une suite infinie de chemins augmentants, les aug-
mentations suivent une loi géométrique et leur somme converge vers une valeur finie qui peut
être aussi éloignée que l’on le souhaite du flot maximum. Cependant, ce contre-exemple a un
impact limité car en pratique les capacités sont très souvent exprimées par des entiers ou des
rationnels et dans ce dernier cas on peut toujours se ramener à un problème avec capacités
entières en multipliant toutes les capacités par le plus petit dénominateur commun.
Avec des capacités entières, une stratégie simple (il y en a d’autres) qui permet d’obtenir
une complexité polynomiale consiste à choisir toujours un chemin augmentant de longueur
minimale [EK72]. Un tel chemin peut être calculé en effectuant une visite en largeur du
graphe residuel (section 23.3) et ce calcul se fait en temps linéaire dans le nombre d’arêtes.
Les meilleurs algorithmes permettent d’avoir une complexité en O(n3 ).
178 Flot maximum et coupe minimale
Chapitre 26
Programmation linéaire
Le point λx + (1 − λ)y = λ(x − y) + y se trouve sur le segment déterminé par les points x
et y. Ainsi un ensemble S est convexe si tout segment qui connecte deux points de l’ensemble
est contenu dans S.
Dans une fonction convexe, le segment qui connecte deux points du graphe de la fonction
domine la fonction.
179
180 Programmation linéaire
min {f (x) | x ∈ S} .
Une propriété remarquable d’un problème d’optimisation convexe est que si un élément
est le minimum dans son voisinage immédiat alors il est aussi le minimum de tout l’ensemble
S. Une situation idéale pour appliquer une stratégie gloutonne (voir chapitre 21).
Dans un problème d’optimisation convexe on peut adopter la stratégie suivante : on
démarre avec un élément admissible x0 ∈ S et à chaque étape on vérifie si dans le voisi-
nage immédiat de xi il y a un élément admissible xi+1 ∈ S tel que f (xi+1 ) < f (xi ). Si un
tel élément n’existe pas on sait que xi est une solution minimale, et sinon on continue la
recherche à partir de xi+1 .
Pour formaliser la notion de voisinage immédiat on rappelle des notions standards de
topologie.
B(x, S, ) = {y ∈ S | ky − xk ≤ } .
Preuve. Soient y ∈ S un autre élément de S. On veut montrer f (y) ≥ f (x). On sait que
pour tout λ ∈ [0, 1] :
z = λx + (1 − λ)y ∈ S .
En particulier, on peut toujours prendre λ ∈]0, 1[ pour que :
kz − xk ≤ .
L’inégalité gauche utilise l’hypothèse que x est un minimum local et l’inégalité droite l’hy-
pothèse que f est convexe. Comme (1 − λ) > 0, on dérive :
(1 − λ)f (x)
f (x) = ≤ f (y) .
(1 − λ)
2
Remarque 36 Une fonction affine est à la fois convexe et concave. Donc pour une fonc-
tion affine sur un ensemble convexe S un minimum (maximum) local est aussi un minimum
(maximum) sur S.
Définition 45 (problème dual) Le problème dual d’un problème de la forme (26.2) est :
min{cT x | Ax ≥ b, x ≥ 0} ,
comme max {bT y | AT y ≤ c, y ≥ 0}. En utilisant le fait que (AT )T = A on vérifie que le dual
du dual est identique au problème de départ. On a donc la correspondance suivante :
Primal Dual
max cT x min bT y
Ax ≤ b AT y ≥ c
x≥0 y≥0.
En d’autres termes, tout élément admissible du problème primal (dual) donne une borne
inférieur (supérieure) pour la solution (si elle existe) du problème dual (primal).
Un problème primal (dual) peut :
1. avoir une solution admissible et optimale,
2. avoir une solution admissible mais pas de maximum (minimum) et
3. ne pas avoir de solution admissible.
La proposition 32 montre que si le primal (dual) est dans le cas 2. alors le dual (primal) est
forcement dans le cas 3. Par ailleurs, la proposition 32 nous donne aussi un critère suffisant
pour l’optimalité. Si x est admissible pour le primal, y est admissible pour le dual et bT y = cT x
alors x est optimal pour le primal et y est optimal pour le dual. Un corollaire de l’algorithme du
simplexe qu’on discutera dans le prochain chapitre 27 est que si on a une solution optimale
2. Le concept de problème dual n’est pas propre à la programmation linéaire ; on retrouve ce concept aussi
dans des problèmes d’optimisation convexe plus généraux que la programmation linéaire.
Programmation linéaire 183
pour le primal (dual) alors on a aussi une solution optimale pour le dual (primal) et les
fonctions coı̈ncident sur ces solutions optimales. Donc il est impossible que le primal (dual)
ait une solution optimale et le dual (primal) en ait pas et il est aussi impossible que la fonction
de coût sur la solution optimale du primal soit strictement inférieure à la fonction de coût
sur la solution optimale du dual. Une dernière situation possible est que primal et dual soient
dans le cas 3. En résumant on a 4 cas possibles (sur 9) : (1, 1), (2, 3), (3, 2) et (3, 3). De plus,
dans le cas (1, 1) les fonctions de coût sur les solutions optimales coı̈ncident.
184 Programmation linéaire
Chapitre 27
Algorithme du simplexe
Problème initiale
On va introduire une méthode pour permuter une variable basique avec une variable non-
basique. Pour formuler cette méthode il convient de réécrire la forme (27.1) de la façon suivante
185
186 Algorithme du simplexe
où l’on suppose que N est l’ensemble des indices des variables non-basiques et B est l’ensemble
des indices des variables basiques ; initialement, N = {1, . . . , n} et B = {n + 1, . . . , n + m}.
max c0 + Σi∈N ci xi
xi = bi − Σj∈N ai,j xj i∈B (27.2)
xk ≥ 0 k ∈B∪N .
Dans cette formulation, on suppose que bi ≥ 0 pour i ∈ B ce qui est équivalent à dire que :
bk si k ∈ B
xk = (27.3)
0 si k ∈ N ,
est admissible. On montrera dans la section 27.4 comment arriver à cette formulation ou
conclure qu’une solution admissible n’existe pas.
Exemple 67
Sélection du pivot
On détermine un indice ‘entrant’ e ∈ N tel que :
ce > 0 . (27.4)
Si aucun indice satisfait cette condition on montrera dans la section 27.3 que la solution (27.3)
est optimale. L’intuition est que actuellement xe = 0 et si on augmente xe on a la possibilité
d’augmenter la fonction objectif.
On détermine un indice ‘sortant’ s ∈ B tel que :
bs bi
= min { | i ∈ B, ai,e > 0} . (27.5)
as,e ai,e
L’intuition est qu’en augmentant xe on risque de rendre les variables basiques négatives. On
va selectionner s parmi les premières variables basiques qui vont tomber à 0. On remarque
que si un tel indice n’existe pas on peut augmenter la valeur de la variable xe de 0 à +∞
tout en gardant xi ≥ 0 pour i ∈ B. Comme ce > 0, il en suit que la fonction de coût va aussi
à +∞ et donc le problème est non-borné (le max n’existe pas). Les nouveaux ensembles de
variables basiques et non-basiques sont donc :
en divisant par as,e > 0, on peut exprimer la variable entrante en fonction de la sortante et
des autres variables non-basiques :
bs as,j 1
xe = − Σj∈N \{e} xj − xs . (27.7)
as,e as,e as,e
En d’autres termes, les coefficients pour la variable entrante sont les suivants :
bs as,j 1
be = as,e , ae,j = as,e (j ∈ N \{e}), ae,s = as,e .
Il s’agit maintenant de remplacer toute occurrence de xe dans les équations pour les autres
variables basiques i ∈ B\{s} par l’expression à droite de l’équation (27.7). Un simple calcul
permet de dériver :
Remarque 37 Comme dans l’élimination de Gauss pour la solution d’un système d’équations
linéaires, les transformations décrites modifient la forme du problème sans affecter l’ensemble
des solutions. La forme écart obtenue admet le mêmes solutions admissibles que celle de départ
et pour toute solution x la valeur de la fonction de côut sur x coı̈ncide avec celle de départ.
27.2 Complexité
On note que les opérations effectuées ne modifient pas l’ensemble des solutions du problème
initial et que le coût d’une mise à jour est O(n·m). On a donc obtenu une nouvelle forme écart
et une nouvelle solution admissible avec une valeur de la fonction objectif qui est c0 ≥ c0 . On
a c0 = c0 si et seulement si bs = 0. Il y a des situations dans lesquelles l’opération de pivot
n’augmente pas la fonction objectif. Pour s’assurer de la terminaison de la méthode il faut
remarquer que :
188 Algorithme du simplexe
1. Chaque forme écart est déterminée par un choix de m variables basiques parmi n + m.
n+m
On a donc au plus m formes écart.
2. Il est possible de donner un critère de sélection (critère de Bland) de la variable entrante
et sortante qui assure qu’on ne boucle jamais sur une suite de formes écart. Le critère
consiste à ordonner de façon totale les indices des variables et à choisir toujours la
variable entrante et sortante avec le plus petit indice parmi celles qui satisfont les
conditions de sélection.
Une forme écart correspond à un sommet d’un polytope et l’opération de pivot correspond
à un déplacement d’un sommet à un sommet adjacent. 1
Le nombre de sommets d’un polytope croit de façon exponentiel dans la dimension m.
Par exemple, un hypercube de dimension m a 2m sommets. On peut effectivement construire
des problème de programmation linéaire pour lesquels le nombre de sommets traversés par
l’algorithme du simplexe est exponentiel. Ces problèmes sont assez artificiels et en pratique
le nombre d’itérations de l’algorithme du simplexe croit plutôt de façon linéaire dans la
dimension du problème.
Si les coefficients initiaux sont rationnels alors il est possible d’effectuer tous les calculs
en restant dans l’ensemble des nombres rationnels. Il est aussi possible de montrer que la
croissance des nombres rationnels calculés est modérée (polynomial dans la taille initiale).
L’algorithme du simplexe est donc un algorithme exponentiel dans le pire des cas qui est
efficace en pratique. Des analyses de complexité comme l’analyse lissée (smoothed analysis)
essayent d’expliquer ce phénomène [ST04]. L’algorithme du simplexe a été mis au point autour
de 1950 par George Dantzig [Dan48]. Le premier algorithme polynomial pour la programma-
tion linéaire a été proposé par [Kha79] en utilisant une méthode de l’ellipsoı̈de complètement
différente de celle du simplexe. En pratique, cet algorithme n’est pas compétitif avec l’al-
gorithme du simplexe. Un deuxième algorithme polynomial dit des points intérieurs a été
proposé par [Kar84]. A ce jour, les mises en oeuvre de cette méthode sont compétitives avec
l’algorithme du simplexe.
La plupart des systèmes disponibles pour la solution de problèmes de programmation
linéaire utilisent des nombres flottants et donc des erreurs d’approximation peuvent se pro-
duire pendant le calcul. Dans ce cas, un calcul des erreurs est nécessaire pour estimer la fiabi-
lité de la solution. Un nombre important de problèmes pratiques de programmation linéaire
produisent des matrices creuses et dans ces cas des techniques spécifiques de mise en oeuvre
peuvent améliorer l’efficacité de façon significative. Autour de 2015, les meilleurs systèmes
non-commerciaux peuvent traiter des problèmes avec environ 105 − 106 contraintes et autant
de variables.
Exemple 70 En continuant l’exemple 69, on peut arriver à la forme écart suivante où les
coefficients de la fonction objectif sont tous négatifs.
max 28 − x63 − x65 − 2x36
x1 = 8 + x63 + x65 − x36
x2 = 4 − 8x33 − 2x35 + x36
x4 = 18 − x23 + x25 .
Une solution optimal pour le problème primal est donc (x1 , x2 , x3 ) = (8, 4, 0) avec objectif égal
à 28 et une solution optimale pour le problème dual est (y1 , y2 , y3 ) = (0, 61 , 23 ) avec objectif
aussi égal à 28.
Preuve. (⇒) Si x est admissible pour (27.11) alors (x, 0) est admissible pour (27.12) et
comme la valeur objectif est 0 il doit s’agir d’une solution optimale.
(⇐) Si la valeur de la fonction objectif du problème auxiliaire est 0 on doit avoir z = 0 et
donc on a une solution admissible pour le problème (27.11). 2
Bibliographie
[AB98] Mohamad Akra and Louay Bazzi. On the solution of linear recurrence equations. Computational
Optimization and Applications, 10(2) :195–210, 1998.
[AKS04] Manindra Agrawal, Neeraj Kayal, and Nitin Saxena. PRIMES is in P. Annals of Mathematics,
160(2) :781–793, 2004.
[Bel54] Richard Bellman. The theory of dynamic programming. Bulletin of the AMS, 60(6) :503–516, 1954.
[Ben84] Jon Bentley. Programming pearls : algorithm design techniques. Communications of the ACM,
27(9) :865–873, 1984.
[BW88] Hans-Juergen Boehm and Mark Weiser. Garbage collection in an uncooperative environment.
Softw., Pract. Exper., 18(9) :807–820, 1988.
[CLRS09] Thomas Cormen, Charles Leiserson, Ronald Rivest, and Clifford Stein. Introduction to algorithms.
MIT Press, 2009. Troisième édition. Existe aussi en français.
[CT65] James Cooley and John W. Tukey. An algorithm for the machine calculation of complex Fourier
series. Math. Comp., 19 :297–301, 1965.
[Dan48] George Dantzig. Programming in a linear structure. Technical report, United States Air Force,
Washington DC., 1948.
[Dij59] Edsger Dijkstra. A note on two problems in connexion with graphs. Numerische Mathematik,
1 :269–271, 1959.
[EK72] Jack Edmonds and Richard M. Karp. Theoretical improvements in algorithmic efficiency for network
flow problems. J. ACM, 19(2) :248–264, 1972.
[FF56] Lester Ford and Delbert Fulkerson. Maximal flow through a network. Canadian journal of mathe-
matics, 8(3) :399–404, 1956.
[Hoa61] C. A. R. Hoare. Algorithm 64 : Quicksort. Commun. ACM, 4(7) :321, 1961.
[Huf52] David Huffman. A method for the construction of minimum-redundancy codes. Proceedings of the
IRE, 40(9) :1098–1101, 1952.
[Kar84] Narendra Karmarkar. A new polynomial-time algorithm for linear programming. Combinatorica,
4(4) :373–396, 1984.
[Kha79] Leonid Khachiyan. A polynomial algorithm in linear programming. Akademiia Nauk SSSR. Doklady,
244 :1093–1096, 1979.
[KO62] Anatoly Karatsuba and Yuri Ofman. Multiplication of many-digital numbers by automatic compu-
ters. Proceedings of the USSR Academy of Sciences, 145 :293–294, 1962. Traduction dans Physics-
Doklady, 7 (1963).
[KP17] Brian Kernighan and Robert Pike. La programmation en pratique. Vuibert, 2017.
[KR14] Brian Kernighan and Dennis Ritchie. Le langage C. Dunod, 2014.
[Mil76] Gary L. Miller. Riemann’s hypothesis and tests for primality. J. Comput. Syst. Sci., 13(3) :300–317,
1976.
[MPS92] J. Ian Munro, Thomas Papadakis, and Robert Sedgewick. Deterministic skip lists. In Proceedings of
the Third Annual ACM/SIGACT-SIAM Symposium on Discrete Algorithms, 27-29 January 1992,
Orlando, Florida., pages 367–375, 1992.
[Pri57] Robert Prim. Shortest connection networks and some generalizations. Bell System Technical Jour-
nal, 36(6) :1389–1401, 1957.
191
192 Bibliographie
[Pug90] William Pugh. Skip lists : A probabilistic alternative to balanced trees. Commun. ACM, 33(6) :668–
676, 1990.
[Rab80] Michael O. Rabin. Probabilistic algorithms in finite fields. SIAM J. Comput., 9(2) :273–280, 1980.
[Sho05] Victor Shoup. A computational introduction to number theory and algebra. Cambridge University
Press, 2005. Disponible en ligne.
[ST04] Daniel A. Spielman and Shang-Hua Teng. Smoothed analysis of algorithms : Why the simplex
algorithm usually takes polynomial time. J. ACM, 51(3) :385–463, 2004.
[Str69] Volker Strassen. Gaussian elimination is not optimal. Numer. Math., 13 :354–356, 1969.
Index
O-notation, 97 booléens, 23
C, 14 borne de Chernoff, 144
éditeur de texte, 18 boucle for, 32
énumérations, 34 boucle while, 31
équation deuxième degré, 30 boule, 180
lcs, 158 branchement, 30
llcs, 159
break, 33 calcul nombre d’inversions, 58
clock, 103 calcul propositionnel, 14
continue, 33 capacité, 173
exit, 33 centre d’un arbre, 205
fclose, 75 chemin, 163
fopen, 75 chemin augmentant, 175
fprintf, 75 chemin, longueur, 163
free, 79 chemin, simple, 163
fscanf, 75 circuit, 164
main, 18 circuit Eulerien, 167
main, arguments, 75 clôture transitive, 208
make, 86 classes P et NP, 14
malloc, 78 coût moyen, 125
printf, 26 codage préfixe, 153
return, 33 code ASCII, 11
scanf, 26 commentaire, 18
time, 103 compilateur gcc, 18
compilation d’un programme C, 18
ABR, fusion, 205 compilation, options, 19
ABR, moyenne somme hauteurs, 139 complexité amortie, 104
affectation, 29 complexité asymptotique, 98
aiguillage switch, 34 complexité en moyenne, 103
Alan Turing, 12 compression de Huffman, 153
algorithme, 11 conjugué, 119
algorithme de fusion, 57 conversion binaire-décimal, 39
algorithme de Kruskal, 210 conversions explicites, cast, 27
algorithme glouton, 151 conversions implicites, 27
algorithme probabiliste de Montecarlo, 132 coupe, 174
allocation de mémoire, 77 coupe minimale, problème, 175
arbre binaire de recherche (ABR), 137 crible d’Ératosthène, 52
arbre de recouvrement minimum, 169
arbre, complet, 106 degré d’un noeud, 163
arbre, hauteur, 106 diagramme de décision binaire, 209
arbre, plein, 106 distance d’édition, 207
arbre, positions, 106 distance euclidienne, 180
arbre, quasi-complet, 106 distribution binomiale négative, 127
arbres binaires, enracinés, ordonnés, 105 distribution géométrique, 126
automate fini, 161
ensemble bien fondé, 91
BDD, 209 ensemble convexe, 179
bloc d’activation, 16 ensemble dénombrable, 11
193
194 Index
récursion terminale, 43
rationnels comme structures, 64
recherche aléatoire, 128
recherche dichotomique, 32, 112
reconnaissance de mots, 160
relation de récurrence, solution, 113
relation de récurrence, 111
relations de récurrence, 58
représentation entiers, 21
représentation nombres en base 2, 11
sémantique, 15
séquentialisation, 29
somme cartésienne, 121
sous-séquence, 158
sous-séquence contiguë maximale, 151
structures avec pointeurs, 77
structures de données, 83
suite de Fibonacci, 46
syntaxe, 15
variable (informatique), 15
196 Index
Annexe A
Problèmes
On présente quelques problèmes un peu plus longs qui sont tirés de sujets d’examen.
Phase de bourrage On complète le texte à chiffrer de façon à que sa longueur soit un multiple de m.
Si n est un multiple de m on ajoute au texte les caractères XY · · · Y où Y est répété m − 1 fois. Sinon, on
ajoute au texte les caractères XY · · · Y où Y est répété m − r − 1 fois et r = n mod m (notez que dans ce
cas 0 < r < m et m − r − 1 ≥ 0). Par exemple, si n = 4, m = 3 et le texte est ABCD alors on est dans le
deuxième cas avec r = 1 et on ajoute le texte XY pour obtenir ABCDXY .
Déchiffrement Le déchiffrement d’un texte obtenu de cette façon est calculé en appliquant la permutation
inverse au texte chiffré par blocs de m caractères et ensuite en éliminant la partie terminale du texte de la
forme XY · · · Y . Dans notre exemple, la permutation inverse est 2, 0, 1 et si on l’applique à CABY DX par
blocs de 3 on obtient ABCDXY et après élimination de XY on revient au texte d’origine ABCD.
1. Programmez une fonction d’en-tête int lonbourrage(int n, int m) qui calcule la longueur d’un texte
composé de n caractères après bourrage relativement à une permutation sur {0, . . . , m − 1}.
2. Programmez une fonction d’en-tête void bourrage(int n, char t[n], int l, char bt[l], int m) qui prend
en argument un tableau t[n] qui contient le texte et un tableau bt[l] non-initialisé dont la longueur
l est exactement celle prévue par la fonction lonbourrage relativement à m. La fonction écrit dans le
tableau bt[l] le texte obtenu de t[n] après bourrage.
3. Programmez une fonction d’en-tête void chif(int l, char bt[l], int m, int perm[m]) qui prend en
argument un texte après bourrage (par rapport à m) représenté par le tableau bt[l] et une permutation
représentée par le tableau perm[m] et écrit le chiffrement du texte dans le tableau bt[l].
4. Programmez une fonction d’en-tête void invperm(int m, int perm[m]) qui calcule la permutation
inverse de celle reçue en entrée dans le tableau perm[m] et écrit le résultat dans le tableau perm[m].
5. Programmez une fonction d’en-tête int dechif(int l, char t[l], int m, int perm[m]) qui prend en
argument un texte après chiffrement représenté par le tableau t[l] et la permutation utilisée pour le
chiffrer représentée par le tableau perm[m] et écrit dans le tableau t[l] le texte après déchiffrement. La
fonction dechif rend aussi comme résultat l’indice du dernier caractère significatif dans le tableau.
197
198 Problèmes
∀ i ∈ {1, . . . , k} ∃ j, ` < i xi = xj + x`
(strictement) les étudiants. On écrit t >e t0 si l’étudiant e préfère strictement le tuteur t au tuteur t0 et on
écrit e >t e0 si le tuteur t préfère strictement l’étudiant e à l’étudiant e0 . Une affectation complète a est une
fonction bijective des étudiants aux tuteurs : a : E → T . Une affectation complète a est stable si pour tout
couple (e, t) ∈ E × T tel que a(e) = t0 et a(e0 ) = t on a : (i) si t >e t0 alors e 6>t e0 et (ii) si e >t e0 alors t 6>e t0 .
On représente les préférences des étudiants par une matrice pe de dimension n × n et les préférences des
tuteurs par une matrice pt de dimension n × n aussi. On suppose E = T = {0, . . . , n − 1} et on indique les
préférences avec un nombre compris entre 0 et n − 1 avec la convention que 0 est le premier choix et n − 1 le
dernier (l’ordre est donc inversé par rapport à l’ordre usuel sur les nombres naturels). Ainsi on a pe[e][t] = r
si et seulement si l’étudiant e place le tuteur t au rang r. Et de même pt[t][e] = r si et seulement si le tuteur t
place l’étudiant e au rang r. On représente une affectation complète par un tableau de n entiers différents qui
varient dans T .
Par exemple, supposons E = T = {0, 1, 2} avec les préférences suivantes (dans cet exemple les matrices
des préférences coı̈ncident !).
1 0 2
pe = pt = 2 1 0
0 2 1
L’affectation où chaque étudiant a son premier choix (a[0] = 1, a[1] = 2, a[2] = 0) est stable. Par ailleurs, si on
prend le premier choix des tuteurs on a aussi une affectation stable. Il y a aussi une troisième affectation stable
où chaque étudiant et chaque tuteur a son deuxième choix. Les autres trois affectations possibles ne sont pas
stables.
1. Programmez une fonction d’en-tête :
qui prend en entrée la matrice préférence des étudiants et initialise la matrice er de façon telle que
er[e][r] = t si et seulement si pe[e][t] = r. En d’autres termes, er[e][r] est le tuteur qui se retrouve au
rang r dans le classement de l’étudiant e.
3. Programmez une fonction d’en-tête :
qui retourne 1 si l’affectation représentée par le tableau a est complète et stable par rapport aux
matrices pe et pt et 0 autrement.
5. Programmez une fonction d’en-tête
qui énumère les affectations complètes jusqu’à en trouver une qui est stable et dans ce cas elle l’imprime
à l’écran.
Par exemple, l’angle SW de la grille correspond à la position 20. A partir de chaque position, on peut aller vers
N,S,W,E en sautant 2 positions ou vers NE, NW, SE, SW en sautant 1 position. Bien sûr ces déplacements
sont possibles seulement si on ne sort pas de la grille. Ainsi de la position 12 (le centre de la grille) on a 4
déplacements possibles (à savoir 0, 4, 20, 24) et dans toutes les autres positions on en a que 3 (par exemple
de 5 on peut aller dans 8, 17, 20). Une trajectoire à partir de la position p est une suite de déplacements qui
commence à la position p, qui ne passe jamais deux fois par la même position et qui termine à une position
où on ne peut plus se déplacer sans aller dans une position déjà visitée. La longueur d’une trajectoire est le
nombre de positions visitées et clairement ce nombre ne peut pas excéder 25. On représente une trajectoire par
un tableau d’entiers int t[25] avec la convention que les positions visitées sont dans l’ordre t[0], t[1], t[2], . . . et
que si la trajectoire comporte i positions avec i < 25 alors t[i] = −1 (et donc les valeurs après le premier −1
ne sont pas significatives). Par exemple, le tableau de 25 entiers suivant représente une trajectoire de longueur
13 qui commence à la position 0 et termine à la position 23 :
t_0={0,12,20,5,8,16,1,13,10,22,14,11,23,-1,0,37,-2,3,41,5,6,77,8,9,10}
1. Écrire 2 fonctions ligne et colonne qui prennent en entrée une position et donnent comme résultat la
ligne et la colonne qui correspondent à la position, respectivement. Par convention on compte les lignes
et les colonnes à partir de 0.
2. Écrire une fonction depl qui prend en entrée une position p et un tableau int d[5] et écrit dans le
tableau d les positions vers lesquelles on peut se déplacer à partir de la position p en ajoutant une
valeur −1 à la fin. Par exemple, si p = 5 alors on écrira les valeurs 8, 17, 20, −1 dans d[0], d[1], d[2], d[3],
respectivement.
3. On représente les positions déjà visitées par un tableau short v[25] tel que v[i] vaut 0 si la position
n’a pas été visitée et 1 autrement. écrire une fonction depl adm qui prend en entrée une position p,
les positions déjà visitées (représentées par un tableau de short) et un tableau int d[5] et écrit dans le
tableau d les positions vers lesquelles on peut se déplacer à partir de la position p et qu’on a pas déjà
visité en ajoutant une valeur −1 à la fin. Par exemple, si p = 5, v[8] = 1, v[17] = 0 et v[20] = 1 alors
on écrira les valeurs 17, −1 dans d[0], d[1], respectivement.
4. Écrire une fonction verifie qui prend en entrée un tableau d’entiers de 25 éléments et qui rend 1 si le
tableau représente une trajectoire et 0 autrement.
5. Pouvez-vous estimer le nombre de trajectoires possibles à partir d’une position initiale donnée ? Pensez-
vous que ce nombre est bien plus petit que 25! = 25 · 24 · · · 2 ?
6. Écrire une fonction imprime qui prend en entrée une trajectoire et l’imprime comme une grille 5 × 5.
Par exemple, la trajectoire t0 ci-dessus doit être imprimée de la façon suivante :
1 7
4 5
9 12 2 8 11
6
3 10 13
7. Écrire un fonction gen qui prend en entrée une position initiale et génère une trajectoire aléatoire à
partir de cette position en utilisant la stratégie suivante : elle calcule les déplacements possibles à
partir de la dernière position et il en sélectionne un avec probabilité uniforme. Vous ferez l’hypothèse
qu’un appel à rand()%m vous donne un entier dans {0, . . . , m − 1} avec une probabilité uniforme.
8. Écrire un fonction echantillon qui prend en entrée une position initiale et un entier n et qui calcule n
trajectoires en utilisant la stratégie aléatoire décrite au point précédent. A la fin du calcul, la fonction
imprime la longueur moyenne des n trajectoires ainsi que une plus longue et une plus courte trajectoire
parmi celles calculées.
9. Écrire une fonction max qui prend en entrée une position initiale, énumère les trajectoires valides
à partir de cette position et imprime une trajectoire de longueur maximale. En particulier, si votre
fonction trouve une trajectoire de longueur 25 elle doit l’imprimer et terminer.
10. Écrire une fonction C min qui prend en entrée une position initiale, énumère les trajectoires valides à
partir de cette position et imprime une trajectoire de longueur minimale. Votre fonction devrait écarter
rapidement les trajectoires qui sont au moins aussi longues que celles déjà trouvées.
Problèmes 201
1. Programmez en C une fonction tournoi d’en-tête void tournoi(int k, int n, int t[n]) qui simule le tournoi
en se basant sur les hypothèses décrites ci-dessus. À la fin du calcul t[0] est donc le nom du gagnant
du tournoi.
2. En supposant que le coût d’un appel à la fonction play est O(1) en temps, déterminez la complexité
asymptotique en temps de la fonction tournoi.
3. On suppose maintenant que les identités des joueurs correspondent aux entiers {0, . . . , n − 1} et qu’on
dispose d’un tableau score de type float score[n] qui associe à chaque joueur son score. Programmez en
C une fonction ranking d’en-tête void ranking(int n, float score[n], int position[n]). La fonction reçoit (i)
le nombre de joueurs n, (ii) le tableau avec leur score score et (iii) un tableau vide position, et écrit
dans ce dernier les noms des joueurs d’après leur score. Ainsi à la fin du calcul le tableau position
représente une permutation sur {0, 1, . . . , n − 1} telle que :
Par exemple, pour n = 4 voici un tableau score possible et le contenu du tableau position à la fin du
calcul :
0 1 2 3
score 5, 4 2, 7 3, 1 7, 8
position 3 0 2 1
Vous pouvez allouer des tableaux auxiliaires d’un type approprié et utiliser une fonction auxiliaire de
tri sur ces tableaux sans la programmer.
4. On suppose maintenant que le tableau position de type int position[n] contient les noms des joueurs
ordonnés d’après leur score. Programmez une fonction affect d’en-tête void affect(int k, int n, int posi-
tion[n], int t[n]) qui affecte les joueurs au tableau t qui représente la configuration initiale du tournoi
en respectant la règle suivante pour i = 1, . . . , k :
Règle : les premiers 2i joueurs ne peuvent pas se rencontrer avant le tour k − i + 1,
ce qui revient à dire que les premiers 2 joueurs ne peuvent pas se rencontrer avant la finale (tour
k), les premiers 4 avant les demi-finales (tour k − 1) et ainsi de suite. Par exemple, en supposant
position[i] = i pour i = 0, 1, . . . , 15 (ce qui revient à dire que 0 est le nom du joueur avec le meilleur
score et 15 le nom du joueur avec le pire score) on peut avoir l’affectation suivante pour le tableau t
qui représente la configuration initiale du tournoi :
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
t[i] 0 8 4 9 2 10 5 11 1 12 6 13 3 14 7 15
5. L’affectation de la question 4 est déterminée par le score des joueurs, ce qui est ennuyeux, car tant
que le score des joueurs ne change pas on joue toujours le même tournoi. On souhaite introduire une
composante aléatoire dans cette affectation tout en respectant la règle de la question 4. Vous disposez
maintenant d’une fonction perm d’en-tête void perm(int i, int j, int r[]). Si i ≤ j et i, j sont dans le
domaine de définition du tableau r alors la fonction perm permute les éléments r[i], r[i + 1], . . . , r[j] avec
202 Problèmes
une probabilité uniforme. Programmez une variante de la fonction affect, disons affect alea, qui a le
même en-tête, respecte toujours la règle de la question 4, mais utilise la fonction perm pour introduire
une composante aléatoire dans la génération du tableau initial. Par rapport à l’exemple de la question
4, la fonction affect alea doit pouvoir générer aussi le tableau :
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
t[i] 0 10 5 9 3 8 7 11 1 15 4 13 2 14 6 12
A.7 Majorité
On cherche à déterminer si parmi les n entiers d’un tableau il y en a un qui paraı̂t k > n/2 fois. On appelle
un tel élément majoritaire. On commence avec un algorithme probabiliste.
1. Programmez une fonction check d’en tête :
qui retourne 1 si m paraı̂t plus que n/2 fois dans le tableau t et 0 autrement.
2. On suppose que le tableau t contient un élément majoritaire m. Estimez la probabilité qu’avec ` tirages
dans le tableau t (indépendants et avec probabilité uniforme) on ne tire jamais m.
3. On suppose la déclaration de type : struct result{short maj; int m}. Programmez une fonction (proba-
biliste !) pmajority d’en tête :
de complexité en temps O(n) telle que la fonction rend la structure {1, m} si le tableau t contient un
élément majoritaire m et la structure {0, −1} sinon. Dans ce dernier cas, le tableau peut quand même
contenir un élément majoritaire avec une probabilité inférieure à (1/230 ).
On cherche maintenant à concevoir un algorithme déterministe pour le même problème. Soit t1 , . . . , tn une
séquence de n entiers. On définit les séquences c0 , c1 , . . . , cn et v1 , . . . , vn par c0 = 0 et
(1, ti+1 ) si ci = 0
(ci+1 , vi+1 ) = (ci + 1, vi ) si ci > 0, ti+1 = vi
(ci − 1, vi ) si ci > 0, ti+1 6= vi
de complexité en temps O(n) telle que la fonction rend la structure {1, m} si le tableau t contient un
élément majoritaire m et la structure {0, −1} sinon.
2. Proposez un algorithme en O(m + n) pour extraire un élément minimum d’un tableau bien formé
non-vide. Vous devez illustrer votre algorithme en utilisant le tableau ci-dessus et expliquer pourquoi
votre algorithme est bien en O(m + n).
3. Programmez la fonction C qui correspond à l’algorithme d’extraction. Vous ferez l’hypothèse que le
symbole ∞ est représenté par la constante INT MAX et que le tableau contient des entiers strictement
plus petits que INT MAX.
4. Proposez un algorithme en O(m + n) pour insérer un entier dans un tableau bien formé non-plein.
5. Programmez la fonction C qui correspond à l’algorithme d’insertion (mêmes hypothèses que pour
l’extraction).
6. Supposons maintenant n = m. Proposez un algorithme pour trier n2 entiers en O(n3 ) qui utilise les
fonctions d’extraction et d’insertion aux points 3. et 4. (bien sûr, une solution qui fait appel à un des
algorithmes de tri étudiés dans le cours n’est pas valide !). Comparez la complexité asymptotique dans
le pire de cas de votre algorithme à celles du tri par insertion et du tri par fusion.
La valeur d est donc une borne supérieure à dp(i, j). Pour calculer dp(i, j) il reste à déterminer la
distance minimale entre un point dans {xord[i], . . . , xord[m]} et un point dans {xord[m+1], . . . , xord[j]}.
Soit B(xm, d, i, j) l’ensemble des points dans Pi,j tels que la distance de leur abscisse de xm est au
plus d. 1 Programmez une fonction points bande d’en-tête :
struct bande {int low; int high;};
struct bande points_bande(int i, int j, struct point xord[], double d)
qui calcule l’ensemble B(xm, d, i, j). Plus précisement, points bande retourne une valeur b de type
struct bande telle que B(xm, d, i, j) = {xord[b.low], . . . , xord[b.high]}. Analysez la complexité asympto-
tique de la fonction points bande.
3. Soit p un point dans B(xm, d, i, j). Bornez le nombre de points qu’on peut trouver dans B(xm, d, i, j)
dont l’ordonnée est à une distance au plus d de l’ordonnée de p. Question auxiliaire/suggestion :
combien de points à une distance au moins d peut-on mettre dans un carré dont le côté mesure d ?
4. Programmez une fonction d’en-tête :
double dpbande(struct bande b, struct point xord[], double d)
qui calcule la distance minimale entre deux points distincts (s’ils existent) dans B(xm, d, i, j). Analysez
la complexité asymptotique de la fonction dpbande.
1. Les points dans B(xm, d, i, j) sont donc dans une bande de largeur 2 · d centrée autour de la droite
composée des points dont l’abscisse est xm.
Problèmes 205
5. Soit C une fonction sur les nombres naturels qui satisfait la récurrence :
Trouvez :
— Un nombre naturel minimal k tel que f (n) = nk et C(n) est O(f ).
— Un nombre naturel minimal k tel que g(n) = n · (log2 n)k et C(n) est O(g).
6. D’après ce que vous avez appris, pensez-vous qu’une approche diviser pour régner permet d’améliorer
la complexité O(n2 ) de la question 1 ?
struct node {int val; struct node * left; struct node * right;};
struct node *allocate_node(int v){
struct node *p=(struct node *)(malloc(sizeof(struct node)));
(p->val)=v; (p->left)=NULL; (p->right)=NULL; return p;}
La fonction prend en entrée un pointeur tree à un ABR et un tableau tab (non-initialisé) de taille n.
Ensuite la fonction écrit les entiers dans l’ABR dans le tableau tab par ordre croissant et rend comme
résultat le nombre d’entiers dans l’ABR. Vous pouvez faire l’hypothèse que le nombre d’entiers dans
l’ABR est au plus n.
2. Par convention, soit −1 la hauteur d’un ABR vide. Définition ABR équilibré : un ABR vide est équilibré
et un ABR non-vide est équilibré si les sous-arbres gauche et droit de la racine sont équilibrés et ont
une hauteur qui diffère au plus de 1.
Écrire une fonction tab2tree d’en tête :
La fonction prend en entrée un tableau tab et deux indices i et j tels que : (i) i ≤ j, (ii) tab[i], . . . , tab[j]
sont définis et (iii) tab[i] < · · · < tab[j] Ensuite la fonction construit un ABR équilibré qui contient les
entiers tab[i],. . . ,tab[j] et rend un pointeur à la racine de l’ABR. Vous devez expliquer pourquoi l’ABR
calculé est équilibré.
3. Bonus Proposez un algorithme en O(n) (en temps) pour calculer un ABR équilibré qui résulte de la
fusion de deux ABR (pas forcement équilibrés) de taille n.
LT = {i ∈ N | deg(i) ≤ 1} .
A Si tous les noeuds de l’arbre T sont des feuilles alors tous les noeuds sont dans le centre CT .
B Sinon, soit T 0 l’arbre obtenu en éliminant de T tous les noeuds qui sont des feuilles (et les arêtes
relatives). Alors le centre de l’arbre T coı̈ncide avec le centre de l’arbre T 0 .
2. Dérivez des propriétés A et B un algorithme qui prend en entrée un arbre T représenté par une table
de listes d’adjacence et retourne comme résultat une liste qui contient (exactement une fois) les noeuds
dans le centre de l’arbre. Illustrez le calcul de votre algorithme sur un arbre avec une petite dizaine
de noeuds.
3. Programmez l’algorithme comme une fonction center d’en-tête :
La fonction center prend en entrée une table de listes d’adjacence qui représente l’arbre et retourne
le pointeur à la liste des noeuds qui se trouvent dans le centre de l’arbre. On admet les définitions
suivantes.
struct node {int val; struct node * next;};
struct node *allocate_node(int v){
struct node *p=(struct node *)(malloc(sizeof(struct node)));
(p->val)=v; (p->next)=NULL; return p;};
struct node * insert(int i, struct node * list){
struct node * q = allocate_node(i); (q->next)=list; return q;};
4. Analysez la complexité asymptotique de votre mise-en-oeuvre en fonction de n (le nombre de noeuds).
et on pose :
R00 =∅
Ri0 ∪ {[ai+1 , bi+1 ]} si Ri0 ∪ {[ai+1 , bi+1 ]} est cohérent
0
Ri+1 =
Ri0 autrement
R0 0
= Rn
R00 =∅
Ri0 ∪ {[ai+1 , bi+1 ]} si Ri0 ∪ {[ai+1 , bi+1 ]} est cohérent
0
Ri+1 =
Ri0 autrement
R0 0
= Rn
3.1 Montrez qu’il y a toujours une solution optimale qui contient [a1 , b1 ].
3.2 Montrez que R0 est toujours une solution optimale.
3.3. Estimez la complexité asymptotique en temps d’une fonction qui calcule R0 à partir d’un tableau
de requêtes ordonnées d’après leur deuxième composante.
Problèmes 207
On généralise le problème en supposant qu’une requête est maintenant un couple ([a, b], w) où w est le
poids de la requête (w > 0) et que l’objectif est de rendre un sous-ensemble R0 des requêtes R qui est cohérent
et qui maximise la somme des poids des requêtes qui le composent.
4. Montrez que dans ce cas les stratégies proposées aux points 2. et 3. ne donnent pas toujours une
solution optimale.
5. Programmez une fonction C d’en tête int sup(int n, int t[n], int x) qui prend en entrée un tableau t de
n entiers ordonnés de façon croissante et un entier x et rend le plus grand indice j tel que t[j] < x et
−1 si un tel indice n’existe pas. Est-il possible de résoudre ce problème en temps O(log n) ? Expliquez.
6. On suppose avoir ordonné les requêtes par :
On définit pour j = 1, . . . , n :
où par convention max (∅) = 0. Proposez un algorithme pour calculer pred (j) pour j = 1, . . . , n. Est-il
possible d’effectuer ce calcul en temps O(n log n) ? Expliquez.
7. Soit Rj = {([ai , bi ], wi ) | 1 ≤ i ≤ j} pour j = 1, . . . , n. Soit Oj le poids maximal d’une solution du
problème Rj où par convention on pose : O0 = 0. Montrez :
8. Proposez un algorithme en temps O(n log n) pour résoudre le problème généralisé (avec poids).
On cherche à programmer un algorithme qui prend en entrée une séquence représentée par un tableau d’entiers
et qui imprime à l’écran une plus longue sous-séquence croissante.
1. Soit ls(i) pour i = 0, . . . , n − 1 la longueur de la plus longue sous-séquence croissante qui termine
avec xi . Clairement ls(0) = 1. Montrez qu’on peut calculer ls(i + 1) en fonction de ls(0), . . . , ls(i) et
estimez en fonction de n la complexité asymptotique du temps de calcul nécessaire au calcul de ls(i)
pour i = 1, . . . , n − 1.
2. Programmez une fonction lsf d’en tête : void lsf(int n, int x[n], int ls[n]), qui prend en entrée une
séquence de longueur n représentée par le tableau x et écrit dans le tableau ls les valeurs ls(0), . . . , ls(n−
1).
3. Programmez une fonction plsc d’en tête : void plsc(int n, int x[n]), qui prend en entrée une séquence
de longueur n représentée par le tableau x et imprime sur la sortie standard (écran) une plus longue
sous-séquence.
d(α, β) comme le coût minimal d’une suite d’opérations qui permet de transformer le mot α en
le mot β.
1. Montrer que d est bien une distance. En particulier, pour tout α, β, γ mots : (i) d(α, β) est un nombre
naturel, (ii) d(α, β) = d(β, α), (iii) d(α, β) = 0 ssi α = β et (iv) d(α, β) ≤ d(α, γ) + d(γ, β).
2. Soit σ = o1 , . . . , on une suite d’opérations et soit σi = o1 , . . . , oi pour i = 0, . . . , n. Soit αi le mot
obtenu en appliquant la séquence σi au mot α. On définit C(σi ) = Σj=1,...,i C(oj ). Montrez que si
C(σi+1 ) = d(α, αi+1 ) alors C(σi ) = d(α, αi ).
3. On dit qu’une suite d’opérations o1 , . . . , om est standard si p(o1 ) ≤ · · · ≤ p(om ). Montrez que pour
tout mot α et pour toute suite d’opérations o1 , . . . , om qui aboutit au mot β avec un coût c on peut
trouver une suite d’opérations standard o01 , . . . , o0n qui aboutit aussi au mot β avec n ≤ m et avec un
coût c0 ≤ c (il suffit donc d’éditer de gauche à droite).
4. On suppose les propriétés suivantes avec a 6= b :
d(, α) = 2|α|
d(αa, βa) = d(α, β)
d(αa, βb) = min{2 + d(α, βb), 2 + d(αa, β), 3 + d(α, β)}
On suppose aussi avoir mémorisé les mots α et β dans deux tableaux de char, a et b de taille m et
n respectivement. Écrire une fonction C distance qui calcule d(α, β). Votre programme doit être assez
efficace pour traiter des mots avec au moins 103 caractères.
8. Montrez que :
G(i, k, k) = G(i, k, k − 1) et G(k, j, k) = G(k, j, k − 1)
9. Dérivez un algorithme avec la même complexité que le précèdent mais qui utilise une seule matrice
n × n (il effectue tous les calculs sur place).
10. Écrire la fonction C qui correspond à l’algorithme optimisé de la question précédente.
Problèmes 209
Vous allez suivre les conventions suivantes : la racine est en haut et les arêtes d’un noeud n à un noeud
b(n) (h(n)) sont des lignes pointillées (continues). Notez qu’en allant de la racine vers les feuilles on
rencontre des étiquettes croissantes d’après l’ordre défini ci dessus.
2. On va représenter un noeud d’un BDD par des valeurs de type :
struct node {int label; struct node * b; struct node * h;};
une variable xi , i = m, . . . , 1 est représentée par l’entier négatif −i et une valeur binaire 0 ou 1 par
les entiers 0 et 1 respectivement (avec ces conventions, l’ordre sur les entiers coı̈ncide avec l’ordre
défini sur les étiquettes). Un BDD sera représenté par un pointeur à la racine du graphe. Programmez
une fonction alloc node d’en-tête struct node ∗ alloc node(int x) qui alloue un struct node avec malloc,
initialise le champ label à x et les champs b et h à NULL et retourne un pointeur au noeud.
3. A partir de maintenant, vous ferez l’hypothèse que l’expression rand()%n donne un entier dans {0, . . . ,
n − 1} avec probabilité uniforme et dans un temps constant O(1). Comment peut-on choisir une
fonction f : 2m → 2, m ≥ 0 avec probabilité uniforme ? Programmez une fonction d’en-tête
struct node ∗ gen bdd(int m) qui prend en argument un entier non-négatif m et retourne un pointeur
à un BDD qui représente une fonction f : 2m → 2 choisie avec probabilité uniforme.
4. Une fonction f : 2m → 2, m ≥ 0 est symétrique si elle est invariante par permutation de ses arguments,
c’est à dire pour toute permutation σ : {1, . . . , m} → {1, . . . , m} et pour tout cm , . . . , c1 ∈ 2 on a
f (cm , . . . , c1 ) = f (cσ(m) , . . . , cσ(1) ). Montrez qu’une fonction f : 2m → 2 est symétrique si et seulement
si il y a une fonction g : {0, . . . , m} → 2 telle que f (cm , . . . , c1 ) = g(Σi=m,...,1 ci ).
5. Programmez une fonction d’en-tête struct node ∗ gen sbdd(int m) qui prend en argument un entier non-
négatif m ≥ 0 et retourne un pointeur à un BDD qui représente une fonction symétrique f : 2m → 2
choisie avec probabilité uniforme.
6. Soit β un BDD. La simplification (S1) consiste à trouver un noeud n dans β tel que v(n) ∈ V
et b(n) = h(n) = n0 et à : (i) rédiriger vers n0 toutes les arêtes vers n, et (ii) éliminer le noeud
n. Le nouveau BDD obtenu définit toujours la même fonction. Programmez une fonction d’en-tête :
struct node ∗ simplify1(struct node ∗ bdd) qui prend en argument le pointeur vers un BDD β et retourne
un pointeur vers un BDD qui définit la même fonction et dans lequel la simplification (S1) ne s’ap-
plique pas. Avant de programmer la fonction, vous expliquerez son fonctionnement sur l’exemple de
la question 1 et vous analyserez sa complexité asymptotique en temps.
7. Soit β un BDD. La simplification (S2) consiste à trouver deux noeuds différents n, n0 dans β tels
que v(n) = v(n0 ) ∈ V , b(n) = b(n0 ) et h(n) = h(n0 ) et à : (i) rédiriger vers n toutes les arêtes vers
210 Problèmes
n0 et (ii) éliminer le noeud n0 . Un BDD réduit est un BDD où les simplifications (S1) et (S2) sont
impossibles. Donnez une borne supérieure au nombre de noeuds d’un BDD réduit qui représente une
fonction symétrique f : 2m → 2.
8. Programmez une fonction d’en-tête struct node ∗ simplify(struct node ∗ bdd) qui prend en argument un
pointeur vers un BDD β et retourne un pointeur vers un BDD réduit qui définit la même fonction.
Avant de programmer la fonction, vous expliquerez son fonctionnement sur l’exemple de la question
1 et vous analyserez sa complexité asymptotique en temps. Pour répondre à cette question il peut
être utile de disposer d’une table de hachage et sachiez qu’il est possible de résoudre le problème en
traversant le BDD une seule fois.
Calcul :
T =∅ (le sous-graphe vide)
a1 , . . . , am énumeration des arêtes de G
for (i = 1; i <= m; i + +){
if(T ∪ {ai } acyclique){T = T ∪ {ai }}
Sortie : T .
1. Montrez que l’algorithme A calcule un arbre de recouvrement (à défaut, vous pouvez supposer ce
résultat).
On suppose les déclarations suivantes :
qui utilise les struct class et le tableau belong ci-dessus. Aussi expliquez les opérations qu’il faut faire
pour implémenter l’opération equal(0,1) et l’opération union(2,5).
6. Peut-on implementer la fonction equal en temps O(1) et la fonction union en temps O(n) ? Expliquez.
7. Programmez une fonction d’en-tête short equal(int n, struct eqclass ∗ belong[n], int i, int j) qui prend en
entrée le nombre de noeuds n, le tableau belong et deux noeuds i et j et retourne 1 si les deux noeuds
sont dans la même classe d’équivalence et 0 autrement.
8. Programmez une fonction d’en-tête void union(int n, struct eqclass ∗ belong[n], int i, int j) qui prend en
entrée le nombre de noeuds n, le tableau belong et deux noeuds i et j et fait l’union des classes
d’équivalence de i et j (on supposera que i n’est pas dans la même classe que j).
Considérons la situation où à partir de la partition P = {{0}, {1}, . . . , {n − 1}} on effectue n − 1 opérations
union. Supposons aussi que chaque fois qu’on fait l’union de deux classes d’équivalencce de la partition on
effectue un travail qui est linéaire dans la taille de la classe d’equivalence plus petite (les éléments de la classe
plus petite rejoignent ceux dans la classe plus grande).
9. Montrez que le coût de (n − 1) opérations union est O(n log n). Suggestion : combien de fois un elément
de N = {0, . . . , n − 1} peut-il changer de classe d’équivalence’ ?
10. Comment peut-on utiliser la structure de données présentée dans le contexte de l’algorithme A ?
Quelle est la complexité de l’algorithme A dans ce cas ?
11. Supposons que les arêtes du graphe G sont pondérées par des poids non-négatifs. Est-ce possible
d’adapter l’algorithme A de façon à qu’il calcule un arbre de recouvrement de poids minimal ? Expli-
quez.