Uploaded by John Jack

Introduction à l'algorithmique Cours et exercices corrigés

advertisement
0 lim Page I Jeudi, 22. juin 2006 5:03 17
INTRODUCTION
À L’ALGORITHMIQUE
Cours et exercices
Thomas Cormen
Professeur associé d’informatique au Darmouth College
Charles Leiserson
Professeur d’informatique au MIT
Ronald Rivest
Professeur d’informatique au MIT
Clifford Stein
Professeur associé au génie industriel
et de recherche opérationelle à l’université de Columbia
Préface de
Philippe chrétienne , Claire Hanen, Alix Munier, Christophe Picouleau
1ère édition traduite de l’américain par Xavier Cazin
Compléments et mises à jour de la 2e édition traduits par Georges-Louis Kocher
2e édition
0 lim Page II Jeudi, 22. juin 2006 5:03 17
L’édition originale de ce livre a été publiée aux États-Unis par The MIT Press, Cambridge,
Massachusetts, sous le titre Introduction to Algorithms, second edition.
© The Massachusetts Institute of Technology, 2001
First edition 1990
Ce pictogramme mérite une explication.
Son objet est d’alerter le lecteur sur
la menace que représente pour l’avenir
de l’écrit, particulièrement dans
le domaine de l’édition technique et universitaire, le développement massif du photocopillage.
Le Code de la propriété
intellectuelle du 1er juillet 1992
interdit en effet expressément la
photocopie à usage collectif
sans autorisation des ayants droit. Or,
cette pratique s’est généralisée dans les
établissements d’enseignement supérieur,
provoquant une baisse brutale des achats
de livres et de revues, au point que la
possibilité même pour les auteurs
de créer des œuvres nouvelles et
de les faire éditer correctement
est aujourd’hui menacée.
Nous rappelons donc que
toute reproduction, partielle ou
totale, de la présente publication
est interdite sans autorisation du
Centre français d’exploitation du
droit de copie (CFC, 20 rue des GrandsAugustins, 75006 Paris).
© Dunod, Paris, 1994, pour la 1ère édition
© Dunod, Paris, 2004, pour la présente édition
ISBN 2 10 003922 9
! " #
$ % $ % $&
' ( ) '& ' *$ % ' & $& & + % , & $ % - Table des matières
PRÉFACE À L’ÉDITION FRANÇAISE
PRÉFACE
XVII
XXI
c Dunod – La photocopie non autorisée est un délit
PARTIE 1 • INTRODUCTION
CHAPITRE 1 • RÔLE DES ALGORITHMES EN INFORMATIQUE
3
1.1 Algorithmes
Exercices
3
8
1.2 Algorithmes en tant que technologie
Exercices
8
11
PROBLÈMES
11
CHAPITRE 2 • PREMIERS PAS
13
2.1 Tri par insertion
Exercices
13
18
2.2 Analyse des algorithmes
Exercices
19
25
2.3 Conception des algorithmes
Exercices
25
34
PROBLÈMES
35
CHAPITRE 3 • CROISSANCE DES FONCTIONS
39
3.1 Notation asymptotique
Exercices
40
48
3.2 Notations standard et fonctions classiques
Exercices
48
54
PROBLÈMES
55
IV
Table des matières
CHAPITRE 4 • RÉCURRENCES
59
4.1 Méthode de substitution
Exercices
60
64
4.2 Méthode de l’arbre récursif
Exercices
64
68
4.3 Méthode générale
Exercices
69
71
4.4 Démonstration du théorème général
Exercices
72
80
PROBLÈMES
80
CHAPITRE 5 • ANALYSE PROBABILISTE ET ALGORITHMES RANDOMISÉS
87
5.1 Le problème de l’embauche
Exercices
87
90
5.2 Variables indicatrices
Exercices
91
94
5.3 Algorithmes randomisés
Exercices
95
100
5.4 Analyse probabiliste et autres emplois des variables indicatrices
Exercices
101
112
PROBLÈMES
113
PARTIE 2 • TRI ET RANGS
CHAPITRE 6 • TRI PAR TAS
121
6.1 Tas
Exercices
121
123
6.2 Conservation de la structure de tas
Exercices
124
125
6.3 Construction d’un tas
Exercices
126
128
6.4 Algorithme du tri par tas
Exercices
129
129
6.5 Files de priorité
Exercices
131
134
PROBLÈMES
135
c Dunod – La photocopie non autorisée est un délit
Table des matières
V
CHAPITRE 7 • TRI RAPIDE
139
7.1 Description du tri rapide
Exercices
139
142
7.2 Performances du tri rapide
Exercices
143
146
7.3 Versions randomisées du tri rapide
Exercices
147
148
7.4 Analyse du tri rapide
Exercices
148
152
PROBLÈMES
153
CHAPITRE 8 • TRI EN TEMPS LINÉAIRE
159
8.1 Minorants pour le tri
Exercices
159
161
8.2 Tri par dénombrement
Exercices
162
164
8.3 Tri par base
Exercices
164
167
8.4 Tri par paquets
Exercices
167
171
PROBLÈMES
171
CHAPITRE 9 • MÉDIANS ET RANGS
177
9.1 Minimum et maximum
Exercices
178
179
9.2 Sélection en temps moyen linéaire
Exercices
179
183
9.3 Sélection en temps linéaire dans le cas le plus défavorable
Exercices
183
186
PROBLÈMES
187
PARTIE 3 • STRUCTURES DE DONNÉES
CHAPITRE 10 • STRUCTURES DE DONNÉES ÉLÉMENTAIRES
195
10.1 Piles et files
Exercices
195
197
VI
Table des matières
10.2 Listes chaînées
Exercices
199
203
10.3 Implémentation des pointeurs et des objets
Exercices
203
207
10.4 Représentation des arborescences
Exercices
208
209
PROBLÈMES
211
CHAPITRE 11 • TABLES DE HACHAGE
215
11.1 Tables à adressage direct
Exercices
216
217
11.2 Tables de hachage
Exercices
218
222
11.3 Fonctions de hachage
Exercices
223
230
11.4 Adressage ouvert
Exercices
231
238
11.5 Hachage parfait
Exercices
238
242
PROBLÈMES
243
CHAPITRE 12 • ARBRES BINAIRES DE RECHERCHE
247
12.1 Qu’est-ce qu’un arbre binaire de recherche ?
Exercices
248
249
12.2 Requête dans un arbre binaire de recherche
Exercices
250
253
12.3 Insertion et suppression
Exercices
254
257
12.4 Arbres binaires de recherche construits aléatoirement
Exercices
258
261
PROBLÈMES
262
CHAPITRE 13 • ARBRES ROUGE-NOIR
267
13.1 Propriétés des arbres rouge-noir
Exercices
267
270
13.2 Rotation
Exercices
271
272
Table des matières
VII
13.3 Insertion
Exercices
273
280
13.4 Suppression
Exercices
281
286
PROBLÈMES
287
CHAPITRE 14 • EXTENSION D’UNE STRUCTURE DE DONNÉES
295
14.1 Rangs dynamiques
Exercices
296
300
14.2 Comment étendre une structure de données
Exercices
301
303
14.3 Arbres d’intervalles
Exercices
304
309
PROBLÈMES
310
c Dunod – La photocopie non autorisée est un délit
PARTIE 4 • TECHNIQUES AVANCÉES DE CONCEPTION ET D’ANALYSE
CHAPITRE 15 • PROGRAMMATION DYNAMIQUE
315
15.1 Ordonnancement de chaînes de montage
Exercices
316
322
15.2 Multiplications matricielles enchaînées
Exercices
323
330
15.3 Éléments de la programmation dynamique
Exercices
330
341
15.4 Plus longue sous-séquence commune
Exercices
341
347
15.5 Arbres binaires de recherche optimaux
Exercices
347
354
PROBLÈMES
354
CHAPITRE 16 • ALGORITHMES GLOUTONS
361
16.1 Un problème de choix d’activités
Exercices
362
370
16.2 Éléments de la stratégie gloutonne
Exercices
370
375
16.3 Codages de Huffman
Exercices
376
382
VIII
Table des matières
16.4 Fondements théoriques
des méthodes gloutonnes
Exercices
383
388
16.5 Un problème d’ordonnancement de tâches
Exercices
389
392
PROBLÈMES
392
CHAPITRE 17 • ANALYSE AMORTIE
395
17.1 Méthode de l’agrégat
Exercices
396
400
17.2 Méthode comptable
Exercices
400
402
17.3 Méthode du potentiel
Exercices
402
405
17.4 Tables dynamiques
Exercices
406
414
PROBLÈMES
415
PARTIE 5 • STRUCTURES DE DONNÉES AVANCÉES
CHAPITRE 18 • B-ARBRES
425
18.1 Définition d’un B-arbre
Exercices
429
431
18.2 Opérations fondamentales sur les B-arbres
Exercices
432
437
18.3 Suppression d’une clé dans un B-arbre
Exercices
439
442
PROBLÈMES
442
CHAPITRE 19 • TAS BINOMIAUX
445
19.1 Arbres binomiaux et tas binomiaux
Exercices
447
450
19.2 Opérations sur les tas binomiaux
Exercices
451
461
PROBLÈMES
462
Table des matières
IX
CHAPITRE 20 • TAS DE FIBONACCI
465
20.1 Structure des tas de Fibonacci
466
20.2 Opérations sur les tas fusionnables
Exercices
469
477
20.3 Diminution d’une clé et suppression d’un nœud
Exercices
478
481
20.4 Borne pour le degré maximal
Exercices
482
484
PROBLÈMES
484
CHAPITRE 21 • STRUCTURES DE DONNÉES POUR ENSEMBLES DISJOINTS
487
21.1 Opérations sur les ensembles disjoints
Exercices
487
490
21.2 Représentation d’ensembles disjoints par des listes chaînées
Exercices
490
493
21.3 Forêts d’ensembles disjoints
Exercices
494
497
21.4 Analyse de l’union par rang avec compression de chemin
Exercices
498
505
PROBLÈMES
506
c Dunod – La photocopie non autorisée est un délit
PARTIE 6 • ALGORITHMES POUR LES GRAPHES
CHAPITRE 22 • ALGORITHMES ÉLÉMENTAIRES POUR LES GRAPHES
513
22.1 Représentation des graphes
Exercices
514
516
22.2 Parcours en largeur
Exercices
517
524
22.3 Parcours en profondeur
Exercices
525
532
22.4 Tri topologique
Exercices
534
536
22.5 Composantes fortement connexes
Exercices
536
541
PROBLÈMES
542
X
Table des matières
CHAPITRE 23 • ARBRES COUVRANTS DE POIDS MINIMUM
545
23.1 Construction d’un arbre couvrant minimum
Exercices
546
550
23.2 Algorithmes de Kruskal et de Prim
Exercices
551
556
PROBLÈMES
558
CHAPITRE 24 • PLUS COURTS CHEMINS À ORIGINE UNIQUE
563
24.1 Algorithme de Bellman-Ford
Exercices
571
574
24.2 Plus courts chemins à origine unique dans les graphes orientés sans circuit
Exercices
575
577
24.3 Algorithme de Dijkstra
Exercices
577
582
24.4 Contraintes de potentiel et plus courts chemins
Exercices
583
587
24.5 Démonstrations des propriétés de plus court chemin
Exercices
589
594
PROBLÈMES
595
CHAPITRE 25 • PLUS COURTS CHEMINS POUR TOUT COUPLE DE SOMMETS
601
25.1 Plus courts chemins et multiplication de matrices
Exercices
603
608
25.2 L’algorithme de Floyd-Warshall
Exercices
609
614
25.3 Algorithme de Johnson pour les graphes peu denses
Exercices
616
620
PROBLÈMES
621
CHAPITRE 26 • FLOT MAXIMUM
625
26.1 Réseaux de transport
Exercices
626
631
26.2 La méthode de Ford-Fulkerson
Exercices
632
643
26.3 Couplage maximum dans un graphe biparti
Exercices
644
648
26.4 Algorithmes de préflots
Exercices
649
658
Table des matières
XI
26.5 Algorithme réétiqueter-vers-l’avant
Exercices
659
669
PROBLÈMES
669
c Dunod – La photocopie non autorisée est un délit
PARTIE 7 • MORCEAUX CHOISIS
CHAPITRE 27 • RÉSEAUX DE TRI
681
27.1 Réseaux de comparaison
Exercices
682
685
27.2 Le principe du zéro-un
Exercices
686
688
27.3 Un réseau de tri bitonique
Exercices
689
690
27.4 Un réseau de fusion
Exercices
692
693
27.5 Un réseau de tri
Exercices
694
696
PROBLÈMES
697
CHAPITRE 28 • CALCUL MATRICIEL
701
28.1 Propriétés des matrices
Exercices
702
709
28.2 Algorithme de Strassen pour la multiplication des matrices
Exercices
710
716
28.3 Résolution de systèmes d’équations linéaires
Exercices
717
730
28.4 Inversion des matrices
Exercices
730
734
28.5 Matrices symétriques définies positives et approximation des moindres carrés
Exercices
735
740
PROBLÈMES
741
CHAPITRE 29 • PROGRAMMATION LINÉAIRE
745
29.1 Forme canonique et forme standard
Exercices
752
759
29.2 Formulation de problèmes comme programmes linéaires
Exercices
760
764
XII
Table des matières
29.3 Algorithme du simplexe
Exercices
765
778
29.4 Dualité
Exercices
779
784
29.5 Solution de base réalisable initiale
Exercices
785
790
PROBLÈMES
791
CHAPITRE 30 • POLYNÔMES ET TRANSFORMÉE RAPIDE DE FOURIER
795
30.1 Représentation des polynômes
Exercices
797
802
30.2 Transformée discrète de Fourier et transformée rapide de Fourier
Exercices
803
810
30.3 Implémentations efficaces de la FFT
Exercices
811
816
PROBLÈMES
816
CHAPITRE 31 • ALGORITHMES DE LA THÉORIE DES NOMBRES
821
31.1 Notions de théorie des nombres
Exercices
823
827
31.2 Plus grand commun diviseur
Exercices
828
832
31.3 Arithmétique modulaire
Exercices
833
839
31.4 Résolution d’équations linéaires modulaires
Exercices
839
842
31.5 Théorème du reste chinois
Exercices
843
845
31.6 Puissances d’un élément
Exercices
846
850
31.7 Le cryptosystème à clés publiques RSA
Exercices
850
856
31.8 Test de primarité
Exercices
856
865
31.9 Factorisation des entiers
Exercices
865
870
PROBLÈMES
870
c Dunod – La photocopie non autorisée est un délit
Table des matières
XIII
CHAPITRE 32 • RECHERCHE DE CHAÎNES DE CARACTÈRES
875
32.1 Algorithme naïf de recherche de chaîne de caractères
Exercices
878
879
32.2 Algorithme de Rabin-Karp
Exercices
880
884
32.3 Recherche de chaîne de caractères au moyen d’automates finis
Exercices
885
891
32.4 Algorithme de Knuth-Morris-Pratt
Exercices
891
898
PROBLÈMES
899
CHAPITRE 33 • GÉOMÉTRIE ALGORITHMIQUE
901
33.1 Propriétés des segments de droite
Exercices
902
907
33.2 Déterminer si deux segments donnés se coupent
Exercices
908
914
33.3 Recherche de l’enveloppe convexe
Exercices
915
924
33.4 Recherche des deux points les plus rapprochés
Exercices
925
929
PROBLÈMES
930
CHAPITRE 34 • NP-COMPLÉTUDE
933
34.1 Temps polynomial
Exercices
939
945
34.2 Vérification en temps polynomial
Exercices
946
950
34.3 NP-complétude et réductibilité
Exercices
951
960
34.4 Preuves de NP-complétude
Exercices
961
968
34.5 Problèmes NP-complets
Exercices
969
982
PROBLÈMES
983
XIV
Table des matières
CHAPITRE 35 • ALGORITHMES D’APPROXIMATION
987
35.1 Problème de la couverture de sommets
Exercices
989
992
35.2 Problème du voyageur de commerce
Exercices
992
997
35.3 Problème de la couverture d’ensemble
Exercices
997
1002
35.4 Randomisation et programmation linéaire
Exercices
1002
1007
35.5 Problème de la somme de sous-ensemble
Exercices
1007
1012
PROBLÈMES
1013
PARTIE 8 • ANNEXES : ÉLÉMENTS DE MATHÉMATIQUES
ANNEXE A • SOMMATIONS
1021
A.1 Formules et propriétés des sommations
Exercices
1022
1025
A.2 Bornes des sommations
Exercices
1025
1031
PROBLÈMES
1031
ANNEXE B • ENSEMBLES, ETC.
1033
B.1 Ensembles
Exercices
1033
1037
B.2 Relations
Exercices
1038
1040
B.3 Fonctions
Exercices
1040
1042
B.4 Graphes
Exercices
1043
1046
B.5 Arbres
Exercices
1047
1053
PROBLÈMES
1054
c Dunod – La photocopie non autorisée est un délit
Table des matières
XV
ANNEXE C • DÉNOMBREMENT ET PROBABILITÉS
1057
C.1 Dénombrement
Exercices
1057
1061
C.2 Probabilités
Exercices
1063
1068
C.3 Variables aléatoires discrètes
Exercices
1069
1073
C.4 Distributions géométrique et binomiale
Exercices
1074
1078
C.5 Queues de la distribution binomiale
Exercices
1079
1084
PROBLÈMES
1085
BIBLIOGRAPHIE
1087
INDEX
1109
c Dunod – La photocopie non autorisée est un délit
Préface à l’édition française
Vous savez compter. Un ordinateur aussi ! Mais connaissez-vous les mécanismes utilisés ? Etes-vous vraiment sûr que le résultat affiché soit juste ? Combien de temps
devrez-vous attendre la fin du calcul ? N’y a-t-il pas un moyen de l’obtenir plus vite ?
Que vous soyez ingénieur, mathématicien, physicien, statisticien et surtout informaticien, toutes ces questions vous vous les posez. Si vous êtes étudiant, elles surgiront
très rapidement.
Étudier l’algorithmique, c’est apporter des réponses à vos questions.
Cette science est le cœur de l’informatique. Pour tout ceux qui doivent ou devront
faire travailler un ordinateur, il est essentiel de comprendre ses principes fondamentaux et de connaître ses éléments de base. Une formule 1 ne se conduit pas comme
une voiture à pédales. De même un ordinateur se s’utilise pas comme un boulier.
L’algorithmique est le permis de conduire de l’informatique. Sans elle, il n’est pas
concevable d’exploiter sans risque un ordinateur.
Cette introduction remarquable à l’algorithmique donne au lecteur d’une part les
bases théoriques indispensables et lui fournit d’autre part les moyens de concevoir rigoureusement des programmes efficaces permettant de résoudre des problèmes variés
issus de différentes applications.
L’éventail des algorithmes présentés va des plus classiques, comme les algorithmes
de tri et les fonctions de hachage, aux plus récents comme ceux de la cryptographie.
On trouve ici rassemblés des algorithmes numériques, par exemple pour l’inversion
de matrices ou la transformée de Fourier et des algorithmes combinatoires comme
les algorithmes de graphes ou la recherche de motif.
Une très large place est faite aux structures de données, des plus simples comme
les listes, aux plus sophistiquées comme les tas de Fibonacci. Notons au passage
l’importance accordée aux différentes mesures de complexité (pire des cas, amortissement, en moyenne) qui permettent d’approfondir entre autres l’étude de l’efficacité
des algorithmes de tri et des structures de données.
XVIII
Préface à l’édition française
Il est certain que la plupart des informaticiens spécialisés trouveront dans ce livre
leurs algorithmes de base, exprimés de façon unifiée, ainsi que certaines avancées
récentes dans leur domaine. Cet ouvrage met donc en relief le rôle central joué par
l’algorithmique dans la science Informatique.
La présence de chapitres méthodologiques comme ceux consacrés à la programmation dynamique et aux algorithmes gloutons, ainsi que les deux derniers qui traitent
de la complexité des problèmes et de la conception d’algorithmes approchés, permet
au lecteur d’amorcer une réflexion plus poussée sur la manière d’aborder un problème et de concevoir une méthode de résolution. Ces chapitres sont illustrés par des
exemples d’application bien choisis et là encore très divers.
Cette gamme de sujets, riche par sa variété et ses niveaux de difficulté, est soutenue
par une pédagogie constante, qui rend la lecture de l’ouvrage facile et agréable, sans
nuire à l’exigence de rigueur. A titre d’exemple, on peut citer la clarté remarquable
des chapitres consacrés aux graphes, qui, à partir d’un algorithme générique, introduisent toute une famille de variantes efficaces dont les différentes implémentations
sont analysées avec finesse.
D’une manière générale, les notions présentées sont systématiquement introduites
de façon informelle à partir d’un exemple ou d’une application particulière, avant
d’être formalisées. Les propriétés et les algorithmes sont toujours démontrés. La présence d’une partie consacrée aux fondements mathématiques utilisés est tout à fait
bienvenue et rend l’ouvrage accessible avec très peu de prérequis.
Le lecteur peut facilement se familiariser et approfondir les notions rencontrées
grâce aux nombreux exercices de difficulté graduée. Les problèmes permettent d’aller plus loin dans la compréhension du chapitre, et sont souvent une occasion de
connaître différentes applications pratiques des algorithmes présentés. De ce point de
vue, ce livre est une mine d’or pour tout enseignant d’algorithmique.
La lecture de cet ouvrage est tout à fait recommandée aux étudiants de second et de
troisième cycle d’informatique et de mathématiques, ainsi qu’aux élèves ingénieurs.
Tous y trouveront une aide et un support de cours utile tout au long de leurs études.
La diversité des sujets abordés, l’efficacité des algorithmes présentés, et leur écriture dans un pseudo-code proche des langages C et Pascal, qui les rend très faciles à
implémenter, font aussi de ce livre un recueil fort utile dans la vie professionelle d’un
informaticien ou d’un ingénieur.
Enfin, au delà de ses besoins propres, nous souhaitons que le lecteur, qu’il soit
ingénieur, étudiant, ou simplement curieux, prenne comme nous plaisir et intérêt à la
lecture de cet ouvrage.
Paris, mars 1994
P HILIPPE C HRÉTIENNE, C LAIRE H ANEN,
A LIX M UNIER, C HRISTOPHE P ICOULEAU
Université Pierre et Marie Curie (LIP6)
Préface à l’édition française
XIX
À propos de la seconde édition :
C’est avec une curiosité renouvelée que l’enseignant, le chercheur ou l’étudiant
abordera cette seconde édition du livre de référence de l’algorithmique. L’algorithmicien déjà familier de la précédente édition y trouvera, parmi moult enrichissements
disséminés tout le long de l’ouvrage, de nouvelles parties, notamment celle dédiée à
la programmation linéaire, de nombreux exercices et problèmes inédits, ainsi qu’une
présentation uniformisée des preuves d’algorithmes ; le lecteur, étudiant ou enseignant, qui découvre cette Introduction à l’algorithmique, y trouvera sous un formalisme des plus limpides, le nécessaire, voire un peu plus, de cette discipline qui est
l’une des pierres angulaires de l’informatique.
c Dunod – La photocopie non autorisée est un délit
Paris, août 2002
C HRISTOPHE P ICOULEAU,
Laboratoire CEDRIC CNAM
c Dunod – La photocopie non autorisée est un délit
Préface
Nous proposons dans ce livre une introduction complète à l’étude contemporaine
des algorithmes informatiques. De nombreux algorithmes y sont présentés et étudiés
en détail, de façon à rendre leur conception et leur analyse accessibles à tous les
niveaux de lecture. Nous avons essayé de maintenir la simplicité des explications,
sans sacrifier ni la profondeur de l’étude, ni la rigueur mathématique.
Chaque chapitre présente un algorithme, une technique de conception, un domaine
d’application, ou un sujet s’y rapportant. Les algorithmes sont décrits en français et
dans un « pseudo-code » conçu pour être lisible par quiconque ayant déjà un peu
programmé. Le livre contient plus de 230 figures qui illustrent le fonctionnement des
algorithmes. Comme nous mettons l’accent sur l’efficacité comme critère de conception, les temps d’exécution de tous nos algorithmes sont soigneusement analysés.
Ce texte est en premier lieu un support du cours d’algorithmique ou de structures
de données de deuxième ou troisième cycle universitaire. Il est également bien adapté
à la formation personnelle des techniciens professionnels, puisqu’il s’intéresse aux
problèmes d’ingénierie ayant trait à la conception d’algorithmes, ainsi qu’à leurs aspects mathématiques.
Dans cette édition, qui est la seconde, nous avons modifié l’ensemble du livre. Les
changements vont de la simple refonte de phrases individuelles jusqu’à l’ajout de
nouveaux chapitres.
a) Pour l’enseignant
Ce livre se veut à la fois complet et polyvalent. Il se révélera utile pour toute sorte de
cours, depuis un cours de structures de données en deuxième cycle jusqu’à un cours
XXII
Préface
d’algorithmique en troisième cycle. Un cours trimestriel étant beaucoup trop court
pour aborder tous les sujets étudiés ici, on peut voir ce livre comme un « buffet garni »
où vous pourrez choisir le matériel le mieux adapté aux cours que vous souhaitez
enseigner.
Vous trouverez commode d’organiser votre cours autour des chapitres dont vous
avez vraiment besoin. Nous avons fait en sorte que les chapitres soient relativement
indépendants, de manière à éviter toute subordination inattendue ou superflue d’un
chapitre à l’autre. Chaque chapitre commence en présentant des notions simples et se
poursuit avec les notions plus difficiles, le découpage en sections créant des points de
passage naturels. Dans un cours de deuxième cycle, on pourra ne faire appel qu’aux
premières sections d’un chapitre donné ; en troisième cycle, on pourra considérer le
chapitre entier.
Nous avons inclus plus de 920 exercices et plus de 140 problèmes. Chaque section
se termine par des exercices et chaque chapitre se termine par des problèmes. Les
exercices sont généralement des questions courtes de contrôle des connaissances.
Certains servent surtout à tester la compréhension du sujet ; d’autres, plus substantiels, sont plutôt du genre devoir à la maison. Les problèmes sont des études de cas
plus élaborées qui introduisent souvent de nouvelles notions ; ils sont composés le
plus souvent de plusieurs questions qui guident l’étudiant à travers les étapes nécessaires pour parvenir à une solution.
Les sections et exercices munis d’une astérisque () recouvrent des thèmes
destinés plutôt aux étudiants de troisième cycle. Un passage étoilé n’est pas forcément plus ardu qu’un passage non étoilé, mais il risque d’exiger des connaissances
mathématiques plus pointues. De même, un exercice étoilé risque de demander
un niveau théorique plus important ou d’incorporer des subtilités au-dessus de la
moyenne.
b) Pour l’étudiant
Nous espérons que ce livre vous fournira une introduction agréable à l’algorithmique.
Nous avons essayé de rendre chaque algorithme accessible et intéressant. Pour vous
aider lorsque vous rencontrez des algorithmes peu familiers ou difficiles, nous les
avons tous décrits étape par étape. Nous expliquons aussi avec soin les notions mathématiques nécessaires pour comprendre l’analyse des algorithmes. Si vous êtes
déjà familiarisé avec un sujet, vous constaterez que l’organisation des chapitres vous
permet de sauter les sections d’introduction et d’aller rapidement aux concepts plus
avancés.
Ceci est un livre volumineux, et votre cours n’en couvrira sans doute qu’une partie.
Nous avons pourtant essayé d’en faire un livre qui vous servira aussi bien maintenant
comme support de cours, que plus tard dans votre carrière, comme référence mathématique, ou manuel d’ingénierie.
Quels sont les pré-requis pour lire ce livre ?
Préface
XXIII
– Vous devrez avoir un petite expérience de la programmation. En particulier vous
devrez comprendre les procédures récursives et les structures de données simples
comme les tableaux et les listes chaînées.
– Vous devrez être relativement familiarisé avec les démonstrations mathématiques
par récurrence. Certaines parties de ce livre reposent sur des connaissances de
calcul élémentaire. Cela dit, les parties 1 et 8 de ce livre vous apprendront toutes
les techniques mathématiques dont vous aurez besoin.
c) Pour le professionnel
La grande variété de sujets présents dans ce livre en fait un excellent manuel de
référence sur les algorithmes. Chaque chapitre étant relativement indépendant des
autres, vous pourrez vous concentrer sur les sujets qui vous intéressent le plus.
La plupart des algorithmes étudiés ont une grande utilité pratique. Nous mettons
donc l’accent sur l’implémentation et les autres problèmes d’ingénierie. Nous offrons
le plus souvent des alternatives pratiques aux quelques algorithmes qui sont surtout
d’intérêt théorique.
Si vous souhaitez implémenter l’un de ces algorithmes, vous n’aurez aucun mal à
traduire notre pseudo-code dans votre langage de programmation favori. Le pseudocode est conçu pour présenter chaque algorithme de façon claire et succincte. Nous
ne nous intéressons donc pas à la gestion des erreurs et autres problèmes de génie
logiciel qui demandent des hypothèses particulières sur l’environnement de programmation. Nous essayons de présenter chaque algorithme simplement et directement de
manière à éviter que leur essence ne soit masquée par les idiosyncrasies d’un langage
de programmation particulier.
c Dunod – La photocopie non autorisée est un délit
d) Pour nos collègues
Nous donnons une bibliographie très complète et des références à la littérature courante. Chaque chapitre se termine par une partie « notes de chapitre » qui fournissent
des détails et des références historiques. Les notes de chapitre ne fournissent pas,
toutefois, une référence complète au vaste champ des algorithmes. Malgré la taille
imposante de cet ouvrage, nous avons dû renoncer, faute de place, à inclure nombre
d’algorithmes intéressants.
Nonobstant les innombrables supplications émanant d’étudiants, nous avons décidé de ne pas fournir de solutions aux problèmes et exercices ; ainsi, l’étudiant ne
succombera pas à la tentation de regarder la solution au lieu d’essayer de la trouver
par lui-même.
e) Modifications apportées à la seconde édition
Qu’est-ce qui a changé par rapport à la première édition de ce livre ? Pas grand chose
ou beaucoup de choses, selon le point de vue adopté.
XXIV
Préface
Un examen sommaire de la table des matières montre que la plupart des chapitres
de la première édition figurent aussi dans la seconde. Nous avons supprimé deux
chapitres et une poignée de sections, mais nous avons ajouté trois nouveaux chapitres
et quatre nouvelles sections réparties sur d’autres chapitres. Si vous deviez évaluer
l’ampleur des modifications d’après la table des matières, vous en concluriez que les
changements ont été limités.
En fait, les modifications vont bien au-delà de ce que semble montrer la table des
matières. Voici, dans le désordre, un résumé des changements les plus significatifs
pour la seconde édition :
– Cliff Stein a rejoint notre équipe d’auteurs.
– Certaines erreurs ont été corrigées. Combien ? Disons, un certain nombre.
– Il y a trois nouveaux chapitres :
• Le chapitre 1 présente le rôle des algorithmes en informatique.
• Le chapitre 5 traite de l’analyse probabiliste et des algorithmes randomisés.
Comme avec la première édition, ces thèmes reviennent souvent dans cet ouvrage.
• Le chapitre 29 est consacré à la programmation linéaire.
– Aux chapitres repris de la première édition ont été ajoutées de nouvelles sections,
traitant des sujets que voici :
• hachage parfait (section 11.5),
• deux applications de la programmation dynamique (sections 15.1 et 15.5), et
• algorithmes d’approximation utilisant randomisation et programmation linéaire
(section 35.4).
– Pour montrer davantage d’algorithmes en début de livre, trois des chapitres consacrés aux théories mathématiques ont été transférés de la partie 1 vers les annexes,
à savoir la partie 8.
– Il y a plus de 40 nouveaux problèmes et plus de 185 nouveaux exercices.
– Nous avons rendu explicite l’utilisation des invariants de boucle pour prouver la
validité des algorithmes. Notre premier invariant apparaît au chapitre 2, et nous en
verrons une trentaine d’autres tout au long de cet ouvrage.
– Nous avons réécrit nombre des analyses probabilistes. En particulier, nous utilisons
à une dizaine d’endroits la technique des « variables indicatrices » qui simplifie les
analyses probabilistes, surtout quand les variables aléatoires sont dépendantes.
– Nous avons enrichi et actualisé les notes de chapitre et la bibliographie. Celleci a augmenté de plus de 50% et nous avons cité nombre de nouveaux résultats
algorithmiques qui ont paru postérieurement à la première édition de ce livre.
Préface
XXV
Nous avons également procédé aux changements suivants :
– Le chapitre consacré à la résolution des récurrences ne contient plus la méthode
d’itération. À la place, dans la section 4.2, nous avons « promu » les arbres récursifs de façon à en faire une méthode de plein droit. Nous avons trouvé que tracer
des arbres récursifs est moins sujet à erreur que d’itérer des récurrences Nous signalons, cependant, que les arbres récursifs sont surtout intéressants pour générer
des conjectures qui seront ensuite vérifiées via la méthode de substitution.
– La méthode de partitionnement employée pour le tri rapide (quicksort) (section 7.1) et pour la sélection en temps moyen linéaire (section 9.2) a changé. Nous
utilisons désormais la méthode de Lomuto qui, grâce à des variables indicatrices,
simplifie quelque peu l’analyse. La méthode utilisée dans la première édition, due
à Hoare, apparaît sous forme de problème au chapitre 7.
– Nous avons modifié la présentation du hachage universel à la section 11.3.3 de
façon que cette étude rentre dans le cadre du hachage parfait.
– Il y a une analyse beaucoup plus simple de la hauteur d’un arbre de recherche
binaire généré aléatoirement, à la section 12.4.
– La présentation de la programmation dynamique (section 15.3) et celle des algorithmes gloutons (section 16.2) ont été fortement enrichies. L’étude du problème
du choix d’activités, exposé au début du chapitre consacré aux algorithmes gloutons, aide à clarifier les relations entre programmation dynamique et algorithmes
gloutons.
– Nous avons remplacé la démonstration du temps d’exécution de la structure de
données union d’ensembles disjoints, à la section 21.4, par une démonstration qui
emploie la méthode du potentiel pour déterminer une borne serrée.
– La démonstration de la validité de l’algorithme de recherche des composantes fortement connexes, à la section 22.5, est plus simple, plus claire et plus directe.
c Dunod – La photocopie non autorisée est un délit
– Le chapitre 24, consacré aux plus courts chemins à origine unique, a été refondu de
façon que les démonstrations des propriétés fondamentales soient placées dans une
section spécifique. Cette nouvelle organisation nous permet de commencer plus tôt
l’étude des algorithmes.
– La section 34.5 contient une présentation enrichie de la NP-complétude, ainsi que
de nouvelles démonstrations de la NP-complétude des problèmes du cycle hamiltonien et de la somme d’un sous-ensemble.
Enfin, nous avons revu quasiment toutes les sections pour corriger, simplifier et clarifier explications et démonstrations.
f) Site web
Un autre changement par rapport à la première édition est ce que livre a maintenant
son site web à lui : http ://mitpress.mit.edu/algorithms/. Vous pouvez utiliser ce site pour signaler des erreurs, obtenir la liste des erreurs connues ou
XXVI
Préface
émettre des suggestions ; n’hésitez pas à nous faire signe. Nous sommes tout particulièrement intéressés par des idées d’exercice et de problème, mais n’oubliez pas d’y
joindre les solutions.
Nous regrettons d’être dans l’impossibilité de répondre personnellement à tous les
commentaires.
g) Remerciements pour la première édition
De nombreux amis et collègues ont largement contribué à la qualité de ce livre. Nous
les remercions tous pour leur aide et leurs critiques constructives.
Le laboratoire d’informatique du MIT nous a offert un environnement de travail idéal. Nos collègues du groupe de théorie du calcul ont été particulièrement
compréhensifs et tolérants quant à nos incessantes demandes d’expertise de tel ou
tel chapitre. Nous remercions particulièrement Baruch Awerbuch, Shafi Goldwasser,
Leo Guibas, Tom Leighton, Albert Meyer, David Shmoys et Eva Tardos. Merci à
William Ang, Sally Bemus, Ray Hirschfeld et Mark Reinhold pour avoir permis à
nos machines (DEC Microvax, Apple Macintosh et Sparcstation Sun) de fonctionner
sans heurts et pour avoir recompilé TEX chaque fois que nous dépassions une limite
de temps de compilation. Thinking Machines Corporation a partiellement permis à
Charles Leiserson de travailler à ce livre alors qu’il était absent du MIT.
De nombreux collègues ont utilisé les épreuves de ce texte pour leur Cours dans
d’autres universités. Ils ont suggéré bon nombre de corrections et de révisions. Nous
souhaitons notamment remercier Richard Beigel, Andrew Goldberg, Joan Lucas,
Mark Overmars, Alan Sherman et Diane Souvaine.
Parmi les enseignants qui nous assistent pour nos cours, beaucoup ont contribué
de manière significative au développement de ce texte. Nous remercions particulièrement Alan Baratz, Bonnie Berger, Aditi Dhagat, Burt Kaliski, Arthur Lent, Andrew
Moulton, Marios Papaefthymiou, Cindy Phillips, Mark Reinhold, Phil Rogaway, Flavio Rose, Arie Rudich, Alan Sherman, Cliff Stein, Susmita Sur, Gregory Troxel et
Margaret Tuttle.
Nombreux sont ceux qui nous ont apporté une assistance technique complémentaire mais précieuse. Denise Sergent a passé de nombreuses heures dans les bibliothèques du MIT, à la recherche de références bibliographiques. Maria Sensale, la
bibliothécaire de notre salle de lecture fut toujours souriante et serviable. L’accès à
la bibliothèque personnelle d’Albert Meyer nous a économisé de nombreuses heures
pendant la rédaction des notes de chapitre. Shlomo Kipnis, Bill Niehaus et David
Wilson ont validé les anciens exercices, en ont conçu de nouveaux et ont annoté leurs
solutions. Marios Papaefthymiou et Gregory Troxel ont contribué à l’index. Au fil des
ans, nos secrétaires Inna Radzihovsky, Denise Sergent, Gayle Sherman, et surtout Be
Hubbard, Ont constamment soutenu ce projet, et nous les en remercions.
Parmi les erreurs relevées dans les premières épreuves, beaucoup l’ont Été par
nos étudiants. Nous remercions particulièrement Bobby Blumofe, Bonnie Eisenberg,
Préface
XXVII
Raymond Johnson, John Keen, Richard Lethin, Mark Lillibridge, John Pezaris, Steve
Ponzio, et Margaret Tuttle pour leur lecture attentive.
Nos collègues ont également effectué des relectures critiques de certains chapitres,
ou donné des informations sur des algorithmes particuliers, et nous leur en sommes
reconnaissants. Nous remercions particulièrement Bill Aiello, Alok Aggarwal, Eric
Bach, Vašek Chvátal, Richard Cole, Johan Hastad, Alex Ishii, David Johnson, Joe
Kilian, Dina Kravets, Bruce Maggs, Jim Orlin, James Park, Thane Plambeck, Hershel
Safer, Jeff Shallit, Cliff Stein, Gil Strang, Bob Tarjan et Paul Wang. Plusieurs de nos
collègues nous ont aussi fourni gracieusement quelques problèmes ; nous remercions
notamment Andrew Goldberg, Danny Sleator et Umesh Vazirani.
Nous avons eu plaisir à travailler avec MIT Press et McGraw-Hill pendant la mise
en forme de ce texte. Nous remercions particulièrement Frank Satlow, Terry Ehling,
Larry Cohen et Lorrie Lejeune de MIT Press et David Shapiro de McGraw-Hill pour
leur encouragement, leur soutien et leur patience. Nous sommes particulièrement reconnaissant à Larry Cohen pour son exceptionnelle correction d’épreuves.
h) Remerciements pour la seconde édition
c Dunod – La photocopie non autorisée est un délit
Quand nous demandâmes à Julie Sussman, P.P.A., de nous servir de correctrice technique pour cette seconde édition, nous ne savions pas que nous allions tomber sur
l’oiseau rare. En plus de vérifier le contenu technique, Julie a corrigé avec ardeur
notre prose. C’est pour nous une belle leçon d’humilité que de voir le nombre d’erreurs que Julie a trouvées dans nos premières épreuves ; encore que, compte tenu
du nombre d’erreurs qu’elle avait décelées dans la première édition (après publication, héla), il n’y a rien d’étonnant à cela. Qui plus est, Julie a sacrifié son emploi
du temps personnel pour favoriser le nôtre ; elle a même relu des chapitres pendant
des vacances aux Iles Vierges ! Julie, comment pourrions-nous vous remercier pour
le travail incroyable que vous avez effectué ?
La seconde édition a été préparée alors que les auteurs faisaient partie du département d’informatique de Dartmouth College et du laboratoire d’informatique du MIT.
Ces deux environnements ne pouvaient être que stimulants, et nous remercions nos
collègues pour leur aide.
Des amis et des collègues, un peu partout dans le monde, nous ont aidé, par leurs
suggestions et leurs avis autorisés, a améliorer notre texte. Grand merci à Sanjeev
Arora, Javed Aslam, Guy Blelloch, Avrim Blum, Scot Drysdale, Hany Farid, Hal
Gabow, Andrew Goldberg, David Johnson, Yanlin Liu, Nicolas Schabanel, Alexander Schrijver, Sasha Shen, David Shmoys, Dan Spielman, Gerald Jay Sussman, Bob
Tarjan, Mikkel Thorup et Vijay Vazirani.
De nombreux enseignants et collègues nous ont appris beaucoup de choses sur les
algorithmes. Nous remercions tout particulièrement nos professeurs Jon L. Bentley,
Bob Floyd, Don Knuth, Harold Kuhn, H. T. Kung, Richard Lipton, Arnold Ross,
XXVIII
Préface
Larry Snyder, Michael I. Shamos, David Shmoys, Ken Steiglitz, Tom Szymanski,
Éva Tardos, Bob Tarjan et Jeffrey Ullman.
Nous remercions, pour leur contribution, tous les assistants chargés de cours d’algorithmique au MIT et à Dartmouth, dont Joseph Adler, Craig Barrack, Bobby Blumofe, Roberto De Prisco, Matteo Frigo, Igal Galperin, David Gupta, Raj D. Iyer,
Nabil Kahale, Sarfraz Khurshid, Stavros Kolliopoulos, Alain Leblanc, Yuan Ma, Maria Minkoff, Dimitris Mitsouras, Alin Popescu, Harald Prokop, Sudipta Sengupta,
Donna Slonim, Joshua A. Tauber, Sivan Toledo, Elisheva Werner-Reiss, Lea Wittie,
Qiang Wu et Michael Zhang.
L’assistance informatique nous a été fournie par William Ang, Scott Blomquist et
Greg Shomo au MIT, et par Wayne Cripps, John Konkle et Tim Tregubov à Dartmouth. Merci également à Be Blackburn, Don Dailey, Leigh Deacon, Irene Sebeda
et Cheryl Patton Wu du MIT, et à Phyllis Bellmore, Kelly Clark, Delia Mauceli,
Sammie Travis, Deb Whiting et Beth Young de Dartmouth pour leur assistance administrative. Michael Fromberger, Brian Campbell, Amanda Eubanks, Sung Hoon
Kim et Neha Narula nous ont aussi apporté une assistance opportune à Dartmouth.
Nombreux sont celles et ceux qui ont eu la gentillesse de signaler des erreurs dans
la première édition. Merci aux personnes suivantes, dont chacune a été la première à
signaler une erreur dissimulée dans la première édition : Len Adleman, Selim Akl,
Richard Anderson, Juan Andrade-Cetto, Gregory Bachelis, David Barrington, Paul
Beame, Richard Beigel, Margrit Betke, Alex Blakemore, Bobby Blumofe, Alexander Brown, Xavier Cazin, Jack Chan, Richard Chang, Chienhua Chen, Ien Cheng,
Hoon Choi, Drue Coles, Christian Collberg, George Collins, Eric Conrad, Peter Csaszar, Paul Dietz, Martin Dietzfelbinger, Scot Drysdale, Patricia Ealy, Yaakov Eisenberg, Michael Ernst, Michael Formann, Nedim Fresko, Hal Gabow, Marek Galecki,
Igal Galperin, Luisa Gargano, John Gately, Rosario Genario, Mihaly Gereb, Ronald
Greenberg, Jerry Grossman, Stephen Guattery, Alexander Hartemik, Anthony Hill,
Thomas Hofmeister, Mathew Hostetter, Yih-Chun Hu, Dick Johnsonbaugh, Marcin Jurdzinki, Nabil Kahale, Fumiaki Kamiya, Anand Kanagala, Mark Kantrowitz,
Scott Karlin, Dean Kelley, Sanjay Khanna, Haluk Konuk, Dina Kravets, Jon Kroger, Bradley Kuszmaul, Tim Lambert, Hang Lau, Thomas Lengauer, George Madrid,
Bruce Maggs, Victor Miller, Joseph Muskat, Tung Nguyen, Michael Orlov, James
Park, Seongbin Park, Ioannis Paschalidis, Boaz Patt-Shamir, Leonid Peshkin, Patricio Poblete, Ira Pohl, Stephen Ponzio, Kjell Post, Todd Poynor, Colin Prepscius,
Sholom Rosen, Dale Russell, Hershel Safer, Karen Seidel, Joel Seiferas, Erik Seligman, Stanley Selkow, Jeffrey Shallit, Greg Shannon, Micha Sharir, Sasha Shen,
Norman Shulman, Andrew Singer, Daniel Sleator, Bob Sloan, Michael Sofka, Volker
Strumpen, Lon Sunshine, Julie Sussman, Asterio Tanaka, Clark Thomborson, Nils
Thommesen, Homer Tilton, Martin Tompa, Andrei Toom, Felzer Torsten, Hirendu
Vaishnav, M. Veldhorst, Luca Venuti, Jian Wang, Michael Wellman, Gerry Wiener,
Ronald Williams, David Wolfe, Jeff Wong, Richard Woundy, Neal Young, Huaiyuan
Yu, Tian Yuxing, Joe Zachary, Steve Zhang, Florian Zschoke et Uri Zwick.
c Dunod – La photocopie non autorisée est un délit
Préface
XXIX
Nombre de nos collègues ont fourni des compte-rendus détaillés ou ont rempli
un long questionnaire. Merci donc à Nancy Amato, Jim Aspnes, Kevin Compton,
William Evans, Peter Gacs, Michael Goldwasser, Andrzej Proskurowski, Vijaya Ramachandran et John Reif. Nous remercions également les personnes suivantes qui
nous nous renvoyé le questionnaire : James Abello, Josh Benaloh, Bryan BeresfordSmith, Kenneth Blaha, Hans Bodlaender, Richard Borie, Ted Brown, Domenico Cantone, M. Chen, Robert Cimikowski, William Clocksin, Paul Cull, Rick Decker, Matthew Dickerson, Robert Douglas, Margaret Fleck, Michael Goodrich, Susanne Hambrusch, Dean Hendrix, Richard Johnsonbaugh, Kyriakos Kalorkoti, Srinivas Kankanahalli, Hikyoo Koh, Steven Lindell, Errol Lloyd, Andy Lopez, Dian Rae Lopez,
George Lucker, David Maier, Charles Martel, Xiannong Meng, David Mount, Alberto Policriti, Andrzej Proskurowski, Kirk Pruhs, Yves Robert, Guna Seetharaman,
Stanley Selkow, Robert Sloan, Charles Steele, Gerard Tel, Murali Varanasi, Bernd
Walter et Alden Wright. Nous aurions aimé pouvoir répondre à toutes vos suggestions, mais cette seconde édition aurait dû alors faire dans les 3000 pages !
La seconde édition a été faite avec LATEX 2´. Michael Downes a converti les macros LATEX pour les faire passer de LATEX « classique » à LATEX 2´ ; il a aussi converti
les fichiers texte pour qu’ils utilisent les nouvelles macros. David Jones a fourni une
assistance sur LATEX 2´. Les figures de la seconde édition ont été réalisées par les
auteurs à l’aide de MacDraw Pro. Comme pour la première édition, l’index a été
compilé avec Windex, qui est un programme C développé par les auteurs ; la bibliographie a été préparée avec B IBTEX. Ayorkor Mills-Tettey et Rob Leathern ont aidé
à la conversion des figures vers MacDraw Pro ; Ayorkor a, en outre, contrôlé notre
bibliographie.
Comme cela avait été le cas pour la première édition, travailler avec The MIT Press
et McGraw-Hill fut un vrai plaisir. Nos superviseurs, Bob Prior pour The MIT Press
et Betsy Jones pour McGraw-Hill, ont supporté nos caprices et nous ont maintenu
dans le droit chemin en maniant adroitement la carotte et le bâton.
Enfin, nous remercions nos femmes (Nicole Cormen, Gail Rivest et Rebecca Ivry),
nos enfants (Ricky, William et Debby Leiserson ; Alex et Christopher Rivest ; Molly,
Noah et Benjamin Stein) et nos parents (Renee et Perry Cormen ; Jean et Mark Leiserson ; Shirley et Lloyd Rivest ; Irene et Ira Stein) pour leur amour et leur soutien
pendant l’écriture de ce livre. La patience et les encouragements de nos familles ont
permis à ce projet de voir le jour. Nous leur dédions affectueusement ce livre.
T HOMAS H. C ORMEN
C HARLES E. L EISERSON
RONALD L. R IVEST
C LIFFORD S TEIN
Mai 2001
Hanover, New Hampshire
Cambridge, Massachusetts
Cambridge, Massachusetts
Hanover, New Hampshire
PARTIE 1
INTRODUCTION
Cette partie présente les fondamentaux qui doivent vous guider pour concevoir et
analyser des algorithmes. Elle a pour objectif d’exposer en douceur la manière de
spécifier les algorithmes, certaines stratégies de conception qui nous serviront tout au
long de ce livre, ainsi que bon nombre des concepts essentiels sous-jacents à l’analyse des algorithmes. Les parties suivantes de ce livre s’appuieront sur toutes ces
fondations.
c Dunod – La photocopie non autorisée est un délit
Le chapitre 1 donne une présentation générale des algorithmes et de leur place dans
les systèmes informatiques modernes. Ce chapitre définit ce qu’est un algorithme
et donne des exemples. Il signale aussi, au passage, que les algorithmes sont une
technologie, au même titre que les matériels, les interfaces utilisateur graphique, les
systèmes orientés objet ou les réseaux.
Au chapitre 2, nous verrons nos premiers algorithmes, lesquels résolvent le problème qui consiste à trier une suite de n nombres. Ils seront écrits en un pseudo
code qui, même s’il n’est pas directement traduisible en quelque langage de programmation traditionnel que ce soit, reflète la structure de l’algorithme de manière
suffisamment claire pour qu’un programmeur compétent puisse le mettre en œuvre
dans le langage de son choix. Les algorithmes de tri que nous étudierons sont le tri
par insertion, qui utilise une stratégie incrémentale, et le tri par fusion, qui utilise une
technique récursive baptisée « diviser pour régner ». Bien que la durée d’exécution
de chacun de ces algorithmes croisse avec la valeur de n, le taux de croissance n’est
pas le même pour les deux algorithmes. Nous déterminerons ces temps d’exécution
au chapitre 2 et introduirons une notation très pratique pour les exprimer.
2
Partie 1
Le chapitre 3 définira plus formellement cette notation, que nous appellerons notation asymptotique. Il commencera par définir plusieurs notations asymptotiques qui
nous serviront à borner, à majorer et/ou à minorer, les durées d’exécution des algorithmes. Le reste du chapitre 3 consistera essentiellement en une présentation de
notations mathématiques. Le but recherché est de garantir que les notations que vous
employez concordent avec celles utilisées dans cet ouvrage, et non de vous enseigner
de nouveaux concepts mathématiques.
Le chapitre 4 approfondira la méthode diviser-pour-régner, introduite au chapitre 2.
En particulier, le chapitre 4 donnera des méthodes pour la résolution des récurrences,
qui sont très utiles pour décrire les durées d’exécution des algorithmes récursifs. Une
technique très puissante ici est celle appelée « méthode générale », qui permet de
résoudre les récurrences induites par les algorithmes de type diviser-pour-régner. Une
bonne partie du chapitre 4 sera consacrée à la démonstration de la justesse de la
méthode générale ; vous pouvez, sans inconvénient aucun, sauter cette démonstration.
Le chapitre 5 introduira l’analyse probabiliste et les algorithmes randomisés. L’analyse probabiliste sert généralement à déterminer la durée d’exécution d’un algorithme
dans le cas où, en raison de la présence d’une distribution probabiliste intrinsèque, le
temps d’exécution peut varier pour différentes entrées de la même taille. Dans certains cas, nous supposerons que les entrées obéissent à une distribution probabiliste
connue, de sorte que nous ferons la moyenne des temps d’exécution sur l’ensemble
des entrées possibles. Dans d’autres cas, la distribution probabiliste ne viendra pas
des entrées, mais de choix aléatoires faits pendant l’exécution de l’algorithme. Un algorithme dont le comportement dépend non seulement de l’entrée, mais aussi de valeurs produites par un générateur de nombres aléatoires est un algorithme randomisé.
Nous pouvons employer des algorithmes randomisés pour garantir une distribution
probabiliste sur les entrées, et ainsi garantir qu’aucune entrée particulière n’entraîne
systématiquement de piètres performances, voire même pour borner les taux d’erreur
des algorithmes qui sont autorisés à donner, dans une certaine limite, des résultats
erronés.
Les annexes A–C renferment d’autres sujets mathématiques qui vous faciliteront
la lecture de cet ouvrage. Vous avez certainement déjà vu une bonne partie de ces notions (bien que les conventions de notation spécifiques que nous utilisons ici puissent,
à l’occasion, différer de ce que vous connaissiez) ; vous pouvez donc considérer les
annexes comme une sorte de référence. En revanche, la plupart des concepts présentés dans la partie 1 sont vraisemblablement inédits pour vous. Tous les chapitres de
la partie 1 et toutes les annexes ont été rédigés dans un esprit très didactique.
Chapitre 1
Rôle des algorithmes
en informatique
Qu’est-ce qu’un algorithme ? En quoi l’étude des algorithmes est-elle utile ? Quel
est le rôle des algorithmes par rapport aux autres technologies informatiques ? Ce
chapitre a pour objectif de répondre à ces questions.
c Dunod – La photocopie non autorisée est un délit
1.1 ALGORITHMES
Voici une définition informelle du terme algorithme : procédure de calcul bien définie qui prend en entrée une valeur, ou un ensemble de valeurs, et qui donne en sortie
une valeur, ou un ensemble de valeurs. Un algorithme est donc une séquence d’étapes
de calcul qui transforment l’entrée en sortie.
L’on peut aussi considérer un algorithme comme un outil permettant de résoudre
un problème de calcul bien spécifié. L’énoncé du problème spécifie, en termes généraux, la relation désirée entre l’entrée et la sortie. L’algorithme décrit une procédure
de calcul spécifique permettant d’obtenir cette relation entrée/sortie.
Supposons, par exemple, qu’il faille trier une suite de nombres dans l’ordre croissant. Ce problème, qui revient fréquemment dans la pratique, offre une base fertile
pour l’introduction de nombre de techniques de conception et d’outils d’analyse standard. Voici comment nous définissons formellement le problème de tri :
Entrée : suite de n nombres a1 , a2 , ..., an .
Sortie : permutation (réorganisation) a1 , a2 , ..., an de la suite donnée en entrée,
de façon que a1 a2 · · · an .
4
1 • Rôle des algorithmes en informatique
Ainsi, à partir de la suite 31, 41, 59, 26, 41, 58, un algorithme de tri produit en
sortie la suite 26, 31, 41, 41, 58, 59. À propos de la suite donnée en entrée, on parle
d’instance du problème de tri. En général, une instance d’un problème consiste en
l’entrée (satisfaisant aux contraintes, quelles qu’elles soient, imposées dans l’énoncé
du problème) requise par le calcul d’une solution au problème.
Le tri est une opération majeure en informatique (maints programmes l’emploient
comme phase intermédiaire), ce qui explique que l’on ait inventé un grand nombre
d’algorithmes de tri. L’algorithme optimal pour une application donnée dépend, entre
autres facteurs, du nombre d’éléments à trier, de la façon dont les éléments sont plus
ou moins triés initialement, des restrictions potentielles concernant les valeurs des
éléments, ainsi que du type de périphérique de stockage à utiliser : mémoire principale, disques ou bandes.
Un algorithme est dit correct si, pour chaque instance en entrée, il se termine
en produisant la bonne sortie. L’on dit qu’un algorithme correct résout le problème
donné. Un algorithme incorrect risque de ne pas se terminer pour certaines instances
en entrée, voire de se terminer sur une réponse autre que celle désirée. Contrairement à ce que l’on pourrait croire, un algorithme incorrect peut s’avérer utile dans
certains cas, si son taux d’erreur est susceptible d’être contrôlé. Nous en verrons un
exemple au chapitre 31, quand nous étudierons des algorithmes servant à déterminer les grands nombres premiers. En général, seuls nous intéresseront toutefois les
algorithmes corrects.
Un algorithme peut être spécifié en langage humain ou en langage informatique,
mais peut aussi être basé sur un système matériel. L’unique obligation est que la
spécification fournisse une description précise de la procédure de calcul à suivre.
a) Quels sont les types de problème susceptibles d’être résolus par des
algorithmes ?
Le tri n’est absolument pas l’unique problème pour lequel ont été inventés des algorithmes. (Vous l’avez sans douté deviné rien qu’en voyant la taille de cet ouvrage.)
Les applications concrètes des algorithmes sont innombrables, entre autres :
– Le projet du génome humain a pour objectifs d’identifier les 100 000 gènes de
l’ADN humain, de déterminer les séquences des 3 milliards de paires de bases
chimiques qui constituent l’ADN humain, de stocker ces informations dans des
bases de données et de développer des outils d’analyse de données. Chacune de
ces étapes exige des algorithmes très élaborés. Les solutions aux divers problèmes
sous-jacents sortent du cadre de ce livre, mais les concepts traités dans nombre
de chapitres de cet ouvrage sont utilisés pour résoudre ces problèmes de biologie,
permettant ainsi aux scientifiques de faire leur travail tout en utilisant les ressources
avec efficacité. Cela fait gagner du temps, aussi bien au niveau des hommes que
des machines, et de l’argent, du fait que les expériences de laboratoire peuvent
ainsi fournir davantage d’informations.
1.1
Algorithmes
5
– Internet permet à des gens éparpillés un peu partout dans le monde d’accéder rapidement à toutes sortes de données. Tout cela repose sur des algorithmes intelligents
qui permettent de gérer et manipuler de grosses masses de données. Exemples de
problèmes à résoudre : recherche de routes optimales pour l’acheminement des
données (ce genre de technique sera présenté au chapitre 24) ; utilisation d’un moteur de recherche pour trouver rapidement les pages contenant tel ou tel type de
données (les techniques afférentes seront vues aux chapitres 11 et 32).
– Le commerce électronique permet de négocier et échanger, de manière électronique, biens et services. Le commerce électronique exige que l’on préserve la
confidentialité de données telles que numéros de carte de crédit, mots de passe
et relevés bancaires. La cryptographie à clé publique et les signatures numériques
(traitées au chapitre 31), qui font partie des technologies fondamentales employées
dans ce contexte, s’appuient sur des algorithmes numériques et sur la théorie des
nombres.
– Dans l’industrie et le commerce, il faut souvent optimiser l’allocation de ressources limitées. Une compagnie pétrolière veut savoir où placer ses puits de façon à maximiser les profits escomptés. Un candidat à la présidence veut savoir
dans quels supports publicitaires il doit investir pour maximiser ses chances d’élection. Une compagnie aérienne désire réaliser l’affectation des équipages aux vols
de telle façon que les coûts soient minimisés, les vols assurés sans défaillance et
la législation respectée. Un fournisseur de services Internet veut savoir où placer
des ressources supplémentaires pour desservir ses clients de manière plus efficace.
Voilà des exemples de problèmes susceptibles d’être résolus par la programmation
linéaire, que nous étudierons au chapitre 29.
c Dunod – La photocopie non autorisée est un délit
Certains détails de ces exemples sortent du cadre de cet ouvrage, mais nous donnerons des techniques qui s’appliquent à ces problèmes et catégories de problème.
Nous montrerons aussi, dans ce livre, comment résoudre maints problèmes concrets,
dont les suivants :
– Soit une carte routière sur laquelle sont indiquées toutes les distances entre intersections adjacentes ; il faut déterminer le trajet le plus court entre deux intersections. Le nombre d’itinéraires peut être énorme, même si l’on n’a pas d’itinéraire
qui se recoupe. Comment trouver le trajet le plus court parmi tous les trajets possibles ? Ici, nous modélisons la carte (qui, elle-même, modélise les routes réelles)
sous la forme d’un graphe (sujet traité au chapitre 10 et à l’annexe B), puis nous
cherchons à déterminer le chemin le plus court entre deux sommets du graphe. Le
chapitre 24 montrera comment résoudre ce problème de manière efficace.
– Soit une suite A1 , A2 , . . . , An de n matrices, dont nous voulons calculer le produit A1 A2 . . . An . La multiplication matricielle étant associative, il existe plusieurs
ordres licites pour calculer le produit. Par exemple, si n = 4, nous pourrions faire
les multiplications comme si le produit était parenthésé de l’une quelconque des façons suivantes : (A1 (A2 (A3 A4 ))), (A1 ((A2 A3 )A4 )), ((A1 A2 )(A3 A4 )), ((A1 (A2 A3 ))A4 )
ou (((A1 A2 )A3 )A4 ). Si toutes les matrices sont carrées (et ont donc la même taille),
1 • Rôle des algorithmes en informatique
6
l’ordre des multiplications n’affecte pas la durée de calcul du produit. Si, en revanche, les matrices sont de tailles différentes (ces tailles étant, toutefois, compatibles au niveau de la multiplication matricielle), alors l’ordre des multiplications
peut faire une très grosse différence. Le nombre d’ordres potentiels de multiplication étant exponentiel en n, essayer tous les ordres possibles risque de demander
beaucoup de temps. Nous verrons au chapitre 15 comment utiliser la technique
générale connue sous le nom de programmation dynamique pour résoudre ce problème de façon beaucoup plus efficace.
– Soit l’équation ax = b (mod n) dans laquelle a, b et n sont des entiers ; nous voulons
trouver tous les entiers x, modulo n, qui satisfont à cette équation. Il peut y avoir
zéro, une ou plusieurs solutions. Nous pourrions essayer de faire x = 0, 1, . . . , n−1
dans l’ordre, mais le chapitre 31 donnera une méthode plus efficace.
– Soient n points du plan, dont nous voulons déterminer l’enveloppe convexe. Cette
enveloppe est le plus petit polygone convexe qui contient les points. Intuitivement, nous pouvons nous représenter chaque point comme un clou planté dans une
planche. L’enveloppe convexe serait alors représentée par un élastique qui entoure
tous les clous. Chaque clou autour duquel s’enroule l’élastique est un sommet de
l’enveloppe convexe. (Voir la figure 33.6 pour un exemple.) N’importe lequel des
2n sous-ensembles des points pourrait correspondre aux sommets de l’enveloppe
convexe. Seulement, il ne suffit pas de savoir quels sont les points qui sont des sommets de l’enveloppe ; il faut aussi connaître l’ordre dans lequel ils apparaissent. Il
existe donc bien des choix pour les sommets de l’enveloppe convexe. Le chapitre
33 donnera deux bonnes méthodes pour la détermination de l’enveloppe convexe.
Ces énumérations sont loin d’être exhaustives (comme vous l’avez probablement
deviné en soupesant ce livre), mais elles témoignent de deux caractéristiques que l’on
retrouve dans bon nombre d’algorithmes intéressants :
1) Il existe beaucoup de solutions à priori, mais la plupart d’entre elles ne sont pas
celles que nous voulons. Trouver une solution qui convienne vraiment, voilà qui
n’est pas toujours évident.
2) Il y a des applications concrètes. Parmi les problèmes précédemment énumérés,
les chemins les plus courts fournissent les exemples les plus élémentaires. Une
entreprise de transport, par exemple une société de camionnage ou la SNCF, a
intérêt à trouver les trajets routiers ou ferroviaires les plus courts, car cela diminue
les coûts de main d’œuvre et d’énergie. Un nœud de routage Internet peut avoir
à déterminer le chemin le plus court à travers le réseau pour minimiser le délai
d’acheminement d’un message.
b) Structures de données
Cet ouvrage présente plusieurs structures de données. Une structure de données est
un moyen de stocker et organiser des données pour faciliter l’accès à ces données
et leur modification. Il n’y a aucune structure de données qui réponde à tous les
1.1
Algorithmes
7
besoins, de sorte qu’il importe de connaître les forces et limitations de plusieurs de
ces structures.
c) Technique
Vous pouvez vous servir de cet ouvrage comme d’un « livre de recettes » pour algorithmes, mais vous risquez tôt ou tard de tomber sur un problème pour lequel il
n’existe pas d’algorithme publié (c’est le cas, par exemple, de nombre des exercices
et problèmes donnés dans ce livre !). Cet ouvrage vous enseignera des techniques
de conception et d’analyse d’algorithme, de façon que vous puissiez créer des algorithmes de votre cru, prouver qu’ils fournissent la bonne réponse et comprendre leur
efficacité.
d) Problèmes difficiles
Une bonne partie de ce livre concerne les algorithmes efficaces. Notre mesure habituelle de l’efficacité est la vitesse, c’est-à-dire la durée que met un algorithme à produire ses résultats. Il existe des problèmes, cependant, pour lesquels l’on ne connaît
aucune solution efficace. Le chapitre 34 étudie un sous-ensemble intéressant de ces
problèmes, connus sous l’appellation de problèmes NP-complets.
c Dunod – La photocopie non autorisée est un délit
En quoi les problèmes NP-complets sont-ils intéressants ? Primo, l’on n’a jamais
trouvé d’algorithme efficace pour un problème NP-complet, mais personne n’a jamais prouvé qu’il ne peut pas exister d’algorithme efficace pour un problème. Autrement dit, l’on ne sait pas s’il existe ou non des algorithmes efficaces pour les problèmes NP-complets. Secundo, l’ensemble des problèmes NP-complets offre la propriété remarquable suivante : s’il existe un algorithme efficace pour l’un quelconque
de ces problèmes, alors il existe des algorithmes efficaces pour tous. Cette relation
entre les problèmes NP-complets rend d’autant plus frustrante l’absence de solutions
efficaces. Tertio, plusieurs problèmes NP-complets ressemblent, sans être identiques,
à des problèmes pour lesquels nous connaissons des algorithmes efficaces. Un petit changement dans l’énoncé du problème peut entraîner un changement majeur au
niveau de l’efficacité du meilleur algorithme connu.
Il est intéressant d’avoir quelques connaissances concernant les problèmes NPcomplets étant donné que, chose surprenante, certains d’entre eux reviennent souvent
dans les applications concrètes. Si l’on vous demande de concocter un algorithme
efficace pour un problème NP-complet, vous risquez de perdre pas mal de temps à
chercher pour rien. Si vous arrivez à montrer que le problème est NP-complet, vous
pourrez alors consacrer votre temps à développer un algorithme efficace fournissant
une solution qui est bonne sans être optimale.
À titre d’exemple concret, prenons le cas d’une entreprise de camionnage ayant
un dépôt central. Chaque jour, elle charge le camion au dépôt puis l’envoie faire des
livraisons à plusieurs endroits. À la fin de la journée, le camion doit revenir au dépôt
de façon à pouvoir être rechargé le jour suivant. Pour réduire les coûts, l’entreprise
8
1 • Rôle des algorithmes en informatique
veut choisir un ordre de livraisons tel que la distance parcourue par le camion soit
minimale. Ce problème, qui n’est autre que le fameux « problème du voyageur de
commerce », est un problème NP-complet. Il n’a pas d’algorithme efficace connu.
Sous certaines hypothèses, cependant, il existe des algorithmes efficaces donnant une
distance globale qui n’est pas trop éloignée de la distance minimale. Le chapitre 35
présente ce genre d’algorithmes, dits « algorithmes d’approximation ».
Exercices
1.1.1 Donnez un exemple concret qui intègre l’un des problèmes suivants : tri, optimisation
de l’ordre de multiplication des matrices, détermination de l’enveloppe convexe.
1.1.2 À part la vitesse, qu’est-ce qui pourrait servir à mesurer l’efficacité dans un contexte
concret ?
1.1.3 Sélectionnez une structure de données que vous avez déjà vue, puis étudiez ses avantages et ses inconvénients.
1.1.4 En quoi le problème du chemin minimal et celui du voyageur de commerce, précédemment mentionnés, se ressemblent-ils ? En quoi sont-ils différents ?
1.1.5 Trouvez un problème concret pour lequel seule conviendra la solution optimale.
Trouvez ensuite un problème pour lequel une solution « approchée » pourra faire l’affaire.
1.2 ALGORITHMES EN TANT QUE TECHNOLOGIE
Supposez que les ordinateurs soient infiniment rapides et que leurs mémoires soient
gratuites. Faudrait-il encore étudier les algorithmes ? Oui, ne serait-ce que pour montrer que la solution ne boucle pas indéfiniment et qu’elle se termine avec la bonne
réponse.
Si les ordinateurs étaient infiniment rapides, n’importe quelle méthode correcte de
résolution d’un problème ferait l’affaire. Vous voudriez sans doute que votre solution
entre dans le cadre d’une bonne méthodologie d’ingénierie (c’est-à-dire qu’elle soit
bien conçue et bien documentée), mais vous privilégierez le plus souvent la méthode
qui est la plus simple à mettre en œuvre.
Si rapides que puissent être les ordinateurs, ils ne sont pas infiniment rapides. Si
bon marché que puisse être la mémoire, elle n’est pas gratuite. Le temps machine est
donc une ressource limitée, et il en est de même de l’espace mémoire. Il faut utiliser
ces ressources avec parcimonie, et des algorithmes performants, en termes de durée
et d’encombrement, vous aideront à atteindre cet objectif.
1.2
Algorithmes en tant que technologie
9
a) Efficacité
Il arrive souvent que des algorithmes conçus pour résoudre le même problème diffèrent fortement entre eux en termes d’efficacité. Ces différences peuvent être bien
plus importantes que celles dues au matériel et au logiciel.
c Dunod – La photocopie non autorisée est un délit
À titre d’exemple, au chapitre 2 nous verrons deux algorithmes de tri. Le premier,
appelé tri par insertion, prend un temps approximativement égal à c1 n2 pour trier n
éléments, c1 étant une constante indépendante de n. La durée du tri est donc, grosso
modo, proportionnelle à n2 . Le second, appelé tri par fusion, prend un temps approximativement égal à c2 n lg n, où lg n désigne log2 n et c2 est une autre constante
indépendante, elle aussi, de n. Le tri par insertion a généralement un facteur constant
inférieur à celui du tri par fusion, de sorte que c1 < c2 . Nous verrons que les facteurs
constants peuvent être beaucoup moins significatifs, au niveau de la durée d’exécution, que la dépendance par rapport au nombre n de données à trier. Là où le tri par
fusion a un facteur lg n dans son temps d’exécution, le tri par insertion a un facteur
n, qui est beaucoup plus grand. Le tri par insertion est généralement plus rapide que
le tri par fusion pour de petits nombres de données mais, dès que le nombre n d’éléments à trier devient suffisamment grand, l’avantage du tri par fusion (lg n contre n)
fait plus que compenser la différence entre les facteurs constants. Même si c1 est très
inférieur à c2 , il y a toujours un palier au-delà duquel le tri par fusion devient plus
rapide.
Pour avoir un exemple concret, comparons un ordinateur rapide (ordinateur A)
exécutant un tri par insertion et un ordinateur lent (ordinateur B) exécutant un tri par
fusion. Ces deux machines doivent chacune trier un tableau d’un million de nombres.
Supposez que l’ordinateur A exécute un milliard d’instructions par seconde et que
l’ordinateur B n’exécute que dix millions d’instructions par seconde, de sorte que
l’ordinateur A est 100 fois plus rapide que l’ordinateur B en termes de puissance de
calcul brute. Pour rendre la différence encore plus sensible, supposez que le meilleur
programmeur du monde écrive le tri par insertion en langage machine pour l’ordinateur A et que le code résultant demande 2n2 instructions pour trier n nombres.
(Ici, c1 = 2.) Le tri par fusion, en revanche, est programmé pour l’ordinateur B par
un programmeur médiocre utilisant un langage de haut niveau avec un compilateur
peu performant, de sorte que le code résultant demande 50n lg n instructions (donc,
c2 = 50). Pour trier un million de nombres, l’ordinateur A demande
2·(106 )2 instructions
= 2 000 secondes,
109 instructions/seconde
alors que l’ordinateur B demande
50·106 lg 106 instructions
≈ 100 secondes,
107 instructions/seconde
Avec un algorithme dont le temps d’exécution croît plus lentement, même si le
compilateur est médiocre la machine B tourne 20 fois plus vite que la machine
10
1 • Rôle des algorithmes en informatique
A ! L’avantage du tri par fusion ressort encore plus si nous trions dix millions de
nombres : là où le tri par insertion demande environ 2,3 jours, le tri par fusion prend
moins de 20 minutes. En général, plus la taille du problème augmente et plus augmente aussi l’avantage relatif du tri par fusion.
b) Algorithmes et autres technologies
L’exemple précédent montre que les algorithmes, à l’instar des matériels informatiques, sont une technologie. Les performances globales du système dépendent autant des algorithmes que des matériels. Comme toutes les autres technologies informatiques, les algorithmes ne cessent de progresser.
L’on peut se demander si les algorithmes sont vraiment si importants que cela sur
les ordinateurs modernes, compte tenu de l’état d’avancement d’autres technologies
telles que
– horloges ultra-rapides, pipelines et architectures superscalaires,
– interfaces utilisateur graphiques conviviales et intuitives,
– systèmes orientés objet, et
– technologies de réseau local et de réseau étendu.
La réponse est oui. Même si certaines applications n’exigent pas explicitement
d’algorithmes au niveau de l’application elle-même (c’est le cas, par exemple, de
certaines applications simples basées sur le web), la plupart intègrent une certaine
dose intrinsèque d’algorithmes. Prenez le cas d’un service basé sur le web qui détermine des trajets pour aller d’un endroit à un autre. (Ce genre de service existe
déjà à l’heure où nous écrivons ces lignes.) Sa mise en œuvre exige des matériels
rapides, une interface utilisateur graphique, des technologies de réseau étendu, voire
des fonctionnalités objet. À cela s’ajoutent des algorithmes pour certains traitements
tels que recherche d’itinéraires (utilisant vraisemblablement un algorithme de chemin
minimal), dessin de cartes et interpolation d’adresses.
Qui plus est, même une application qui n’emploie pas d’algorithmes au niveau de
l’application elle-même s’appuie indirectement sur une foule d’algorithmes. L’application s’exécute sur des matériels performants ? La conception de ces matériels a
utilisé des algorithmes. L’application tourne par dessus des interfaces graphiques ?
Toutes les interfaces utilisateur graphiques reposent sur des algorithmes. L’application fonctionne en réseau ? Le routage s’appuie fondamentalement sur des algorithmes. L’application a été écrite dans un langage autre que du code machine ? Alors,
elle a été traduite par un compilateur, un interpréteur ou un assembleur, toutes ces
belles choses faisant un usage intensif d’algorithmes. Les algorithmes sont au cœur
de la plupart des technologies employées dans les ordinateurs modernes.
Enfin, avec les capacités sans cesse accrues des ordinateurs, ces derniers traitent
des problèmes de plus en plus vastes. Comme nous l’avons vu dans la comparaison
Notes
11
entre le tri par insertion et le tri par fusion, c’est avec les problèmes de grande taille
que les différences d’efficacité entre algorithmes sont les plus flagrantes.
Posséder une base solide en algorithmique, voilà qui fait toute la différence entre
le programmeur d’élite et le programmeur lambda. Les technologies informatiques
modernes permettent de faire certaines tâches même si l’on ne s’y connaît guère
en algorithmes ; mais avec une bonne formation en algorithmique, l’on peut aller
beaucoup, beaucoup plus loin.
Exercices
1.2.1 Donnez un exemple d’application exigeant des algorithmes intrinsèques, puis discutez
les fonctions des algorithmes concernés.
1.2.2 On veut comparer les implémentations du tri par insertion et du tri par fusion sur la
même machine. Pour un nombre n d’éléments à trier, le tri par insertion demande 8n2 étapes
alors que le tri par fusion en demande 64n lg n. Quelles sont les valeurs de n pour lesquelles
le tri par insertion l’emporte sur le tri par fusion ?
1.2.3 Quelle est la valeur minimale de n pour laquelle un algorithme dont le temps d’exécution est 100n2 s’exécute plus vite qu’un algorithme dont le temps d’exécution est 2n sur la
même machine ?
PROBLÈMES
c Dunod – La photocopie non autorisée est un délit
1.1. Comparaison de temps d’exécution
Pour chaque fonction f (n) et pour chaque durée t du tableau suivant, déterminez la
taille maximale n d’un problème susceptible d’être résolu dans le temps t, en supposant que l’algorithme mette f (n) microsecondes pour traiter le problème.
1
1
1
1
1
1
1
seconde minute heure jour mois an siècle
lg
√n
n
n
n lg n
n2
n3
2n
n!
12
1 • Rôle des algorithmes en informatique
NOTES
Il existe nombre de textes excellents sur le thème général des algorithmes, dont les ouvrages
de Aho, Hopcroft et Ullman [5, 6], Baase et Van Gelder [26], Brassard et Bratley [46, 47],
Goodrich et Tamassia [128], Horowitz, Sahni et Rajasekaran [158], Kingston [179], Knuth
[182, 183, 185], Kozen [193], Manber [210], Mehlhorn [217, 218, 219], Purdom et Brown
[252], Reingold, Nievergelt et Deo [257], Sedgewick [269], Skiena [280] et Wilf [315]. Certains des aspects plus concrets de la conception des algorithmes sont traités par Bentley [39,
40] et Gonnet [126]. Vous trouverez aussi une introduction à l’algorithmique dans les ouvrages intitulés Handbook of Theoretical Computer Science, Volume A [302] et CRC Handbook on Algorithms and Theory of Computation [24]. Enfin, vous trouverez des présentations
d’algorithmes utilisés en biologie informatique dans les manuels de Gusfield [136], Pevzner
[240], Setubal et Medinas [272] et Waterman [309].
Chapitre 2
c Dunod – La photocopie non autorisée est un délit
Premiers pas
Ce chapitre a pour but de vous familiariser avec la démarche que nous adopterons
dans ce livre quand il s’agira de réfléchir à la conception et à l’analyse des algorithmes. On peut le lire indépendamment de la suite, bien qu’il fasse plusieurs fois
référence à des notions qui seront abordées dans les chapitres 3 et 4. (Il contient aussi
plusieurs sommations que l’annexe A montrera comment résoudre).
Nous commencerons par étudier l’algorithme du tri par insertion, afin de résoudre
la problématique du tri exposée au chapitre 1. Nous introduirons un « pseudo code »
qui ne devrait pas surprendre le lecteur ayant déjà pratiqué la programmation, et qui
nous servira à spécifier nos algorithmes. Après avoir décrit en pseudo code l’algorithme du tri par insertion, nous montrerons qu’il fait bien ce qu’on attend de lui et
nous analyserons son temps d’exécution. L’analyse permettra d’introduire une notation qui met en valeur la façon dont le temps d’exécution croît avec le nombre
d’éléments à trier. Après avoir étudié le tri par insertion, nous présenterons le paradigme « diviser pour régner » pour la conception d’algorithmes, technique qui nous
servira à développer l’algorithme du tri par fusion. Nous terminerons par l’analyse
du temps d’exécution du tri par fusion.
2.1 TRI PAR INSERTION
Notre premier algorithme, à savoir le tri par insertion, résout la problématique du tri
exposée au chapitre 1 :
Entrée : Suite de n nombres a1 , a2 , . . . , an .
2 • Premiers pas
14
Sortie : permutation (réorganisation) a1 , a2 , . . . , an de la suite donnée en entrée,
de façon que a1 a2 · · · an .
Les nombres à trier sont parfois appelés clés.
Dans cet ouvrage, nous exprimerons généralement les algorithmes sous la forme
de programmes écrits en un pseudo code qui, à certains égards, rappelle C, Pascal
ou Java. Si vous connaissez déjà l’un de ces langages, vous n’aurez guère de mal à
lire nos algorithmes. Ce qui différencie le pseudo code du « vrai » code c’est que,
avec le pseudo code nous employons l’écriture qui nous semble être la plus claire
et la plus concise pour spécifier l’algorithme ; ne soyez donc pas surpris de voir apparaître un mélange de français et de « vrai » code. Autre différence entre pseudo
code et vrai code : le pseudo code ne se soucie pas, en principe, de problèmes d’ingénierie logicielle tels qu’abstraction des données, modularité, traitement d’erreur, etc.
Cela permet au pseudo code de refléter plus clairement la substantifique moelle de
l’algorithme.
Nous commencerons par le tri par insertion, qui est un algorithme efficace quand il
s’agit de trier un petit nombre d’éléments. Le tri par insertion s’inspire de la manière
dont la plupart des gens tiennent des cartes à jouer. Au début, la main gauche du
joueur est vide et ses cartes sont posées sur la table. Il prend alors sur la table les
cartes, une par une, pour les placer dans sa main gauche. Pour savoir où placer une
carte dans son jeu, le joueur la compare avec chacune des cartes déjà présentes dans
sa main gauche, en examinant les cartes de la droite vers la gauche, comme le montre
la figure 2.1. A tout moment, les cartes tenues par la main gauche sont triées ; ces
cartes étaient, à l’origine, les cartes situées au sommet de la pile sur la table.
♣♣
♣
♣♣
10
5♣ ♣
4 ♣♣
♣♣ ♣
♣
♣
♣
♣♣ ♣
7
♣
10
♣♣
♣
5♣
♣♣
♣
4 2♣
♣
♣
♣ ♣♣ ♣
♣♣
7
♣
2
♣
Figure 2.1 Tri de cartes à jouer, via tri par insertion.
Notre pseudo-code pour le tri par insertion se présente sous la forme d’une procédure appelée T RI -I NSERTION. Elle prend comme paramètre un tableau A[1 . . n] qui
2.1
Tri par insertion
15
contient une séquence à trier, de longueur n. (Dans le code, le nombre n d’éléments
de A est noté longueur[A]). Les nombres donnés en entrée sont triés sur place : ils
sont réorganisés à l’intérieur du tableau A, avec tout au plus un nombre constant
d’entre eux stocké à l’extérieur du tableau à tout instant. Lorsque T RI -I NSERTION se
termine, le tableau d’entrée A contient la séquence de sortie triée.
T RI -I NSERTION(A)
1 pour j ← 2 à longueur[A]
2
faire clé ← A[j]
3
Insère A[j] dans la séquence triée A[1 . . j − 1].
4
i←j−1
5
tant que i > 0 et A[i] > clé
6
faire A[i + 1] ← A[i]
7
i←i−1
8
A[i + 1] ← clé
a) Invariants de boucle et validité du tri par insertion
1
2
3
4
5
6
(a)
5
2
4
6
1
3
1
2
3
4
5
6
(d)
2
4
5
6
1
3
1
2
3
4
5
6
(b)
2
5
4
6
1
3
1
2
3
4
5
6
(e)
1
2
4
5
6
3
1
2
3
4
5
6
(c)
2
4
5
6
1
3
1
2
3
4
5
6
(f)
1
2
3
4
5
6
c Dunod – La photocopie non autorisée est un délit
Figure 2.2 Fonctionnement de T RI -I NSERTION sur le tableau A = 5, 2, 4, 6, 1, 3. Les indices
apparaissent au-dessus des cases, les valeurs du tableau apparaissant dans les cases. (a)–(e) Itérations de la boucle pour des lignes lines 1–8. À chaque itération, la case noire renferme la clé lue
dans A[j] ; cette clé est comparée aux valeurs des cases grises situées à sa gauche (test en ligne 5).
Les flèches grises montrent les déplacements des valeurs d’une position vers la droite (ligne 6),
alors que les flèches noires indiquent vers où sont déplacées les clés (ligne 8). (f)Tableau trié final.
La figure 2.2 illustre le fonctionnement de cet algorithme pour A = 5, 2, 4, 6,
1, 3. L’indice j indique la « carte courante » en cours d’insertion dans la main.
Au début de chaque itération de la boucle pour « extérieure », indiciée par j,
le sous-tableau composé des éléments A[1 . . j − 1] correspond aux cartes qui
sont déjà dans la main, alors que les éléments A[j + 1 . . n] correspondent aux
cartes qui sont encore sur la table. En fait, les éléments A[1 . . j − 1] sont les
éléments qui occupaient initialement les positions 1 à j − 1, mais qui depuis
ont été triés. Nous utiliserons ces propriétés de A[1 . . j − 1] pour définir ce que
l’on appelle, de manière plus formelle, un invariant de boucle : Au début de
chaque itération de la boucle pour des lignes1–8, le sous-tableau A[1 . . j − 1]
se compose des éléments qui occupaient initialement les positions A[1 . . j − 1]
mais qui sont maintenant triés.
2 • Premiers pas
16
Les invariants de boucle nous aideront à comprendre pourquoi un algorithme est
correct. Nous devons montrer trois choses, concernant un invariant de boucle :
Initialisation : Il est vrai avant la première itération de la boucle.
Conservation : S’il est vrai avant une itération de la boucle, il le reste avant l’itération suivante.
Terminaison : Une fois terminée la boucle, l’invariant fournit une propriété utile qui
aide à montrer la validité de l’algorithme.
Si les deux premières propriétés sont vérifiées, alors l’invariant est vrai avant chaque
itération de la boucle. Notez la ressemblance avec la récurrence mathématique dans
laquelle, pour démontrer qu’une propriété est vraie, vous montrez, primo qu’elle est
vraie pour une valeur initiale, secundo que, si elle est vraie pour le rang N, alors elle
l’est pour le rang N + 1 (phase inductive).
La troisième propriété est peut-être la plus importante, vu que nous utilisons l’invariant de boucle pour prouver la validité de l’algorithme. Elle diffère aussi de l’usage
habituel de la récurrence mathématique, dans laquelle la phase inductive se répète
indéfiniment ; ici, on arrête « l’induction » quand la boucle se termine.
Voyons comment ces propriétés s’appliquent au tri par insertion.
Initialisation : Commençons par montrer que l’invariant est vérifié avant la première
itération de la boucle, quand j = 2.(1) Le sous-tableau A[1 . . j−1] se compose donc
uniquement de l’élément A[1] qui est, en fait, l’élément originel de A[1]. En outre,
ce sous-tableau est trié (c’est une trivialité), ce qui montre bien que l’invariant est
vérifié avant la première itération de la boucle.
Conservation : Passons ensuite à la deuxième propriété : montrer que chaque itération conserve l’invariant. De manière informelle, le corps de la boucle pour extérieure fonctionne en déplaçant A[j − 1], A[j − 2], A[j − 3], etc. d’une position vers
la droite jusqu’à ce qu’on trouve la bonne position pour A[j] (lignes 4–7), auquel
cas on insère la valeur de A[j] (ligne 8). Un traitement plus formel de la deuxième
propriété nous obligerait à formuler et prouver un invariant pour la boucle tant
que « intérieure ». Pour l’instant, nous ne nous encombrerons pas d’un tel formalisme et nous nous appuierons uniquement sur notre analyse informelle pour
montrer que la deuxième propriété est vérifiée pour la boucle extérieure.
Terminaison : Enfin, voyons ce qui se passe quand la boucle se termine. Pour le tri
par insertion, la boucle pour extérieure prend fin quand j dépasse n, c’est-à-dire
quand j = n+1. En substituant n+1 à j dans la formulation de l’invariant de boucle,
l’on a que le sous-tableau A[1 . . n] se compose des éléments qui appartenaient
originellement à A[1 . . n] mais qui ont été triés depuis lors. Or, le sous-tableau
(1) Quand la boucle est une boucle pour, le moment où nous contrôlons l’invariant avant la première itération
se situe juste après l’assignation initiale à la variable servant de compteur de boucle et juste avant le premier
test dans l’en-tête de boucle. Dans le cas de T RI -I NSERTION, cet instant se situe après affectation de la valeur
2 à la variable j mais avant le premier test vérifiant si j longueur[A].
2.1
Tri par insertion
17
A[1 . . n] n’est autre que le tableau complet ! Par conséquent, le tableau tout entier
est trié, et donc l’algorithme est correct.
Nous reverrons plus loin dans ce chapitre, ainsi que dans d’autres chapitres, cet
emploi des invariants de boucle pour justifier la validité des algorithmes.
b) Conventions concernant le pseudo code
Nous adopterons les conventions suivantes pour le pseudo code.
1) L’indentation indique une structure de bloc. Par exemple le corps de la boucle
pour qui commence à la ligne 1 se compose des lignes 2–8, et le corps de la
boucle tant que qui commence à la ligne 5 contient les lignes 6–7 mais pas la ligne
8. Notre style d’indentation s’applique également aux instructions si-alors-sinon.
L’emploi de l’indentation, au lieu d’indicateurs du genre début et fin,pour matérialiser les blocs réduit sensiblement l’encombrement tout en préservant, voire
améliorant, la clarté. (2) .
2) Les boucles tant que, pour et répéter, ainsi que tests si, alors et sinon ont
la même signification qu’en Pascal. (3) Il existe, toutefois, une différence subtile concernant les boucles pour : en Pascal la valeur de la variable servant de
compteur de boucle est indéfinie à la sortie de la boucle, alors que dans ce livre
le compteur de boucle conserve sa valeur après la fin de la boucle. Ainsi donc,
juste après une boucle pour, la valeur du compteur de boucle est la valeur immédiatement supérieure à la limite de bouclage. Nous avons utilisé cette propriété
dans notre démonstration de la conformité du tri par insertion. L’en-tête de la
boucle pour, en ligne 1, est pour j ← 2 à longueur[A] ; donc, quand la boucle
se termine, j = longueur[A] + 1 (ou, de manière équivalente, j = n + 1, puisque
n = longueur[A]).
3) Le symbole « » indique que le reste de la ligne est un commentaire.
c Dunod – La photocopie non autorisée est un délit
4) Une affectation multiple de la forme i ← j ← e affecte aux deux variables i et j la
valeur de l’expression e ; on la considère comme équivalente à l’affectation j ← e
suivie de l’affectation i ← j.
5) Les variables comme i, j et clé sont locales à la procédure donnée. Nous n’utiliserons pas de variables globales, sauf indication explicite.
6) On accède aux éléments d’un tableau via le nom du tableau suivi de l’indice entre
crochets. Ainsi, A[i] représente le i-ème élément du tableau A. La notation « . . »
indique une plage de valeurs dans un tableau. A[1 . . j] représente donc le soustableau de A constitué des éléments A[1], A[2], . . . , A[j].
(2) Dans un vrai langage de programmation, il est généralement déconseillé d’utiliser la seule indentation
pour représenter les blocs, car il est difficile de repérer les niveaux d’indentation lorsque le code tient sur
plusieurs pages.
(3) La plupart des langages structurés ont des constructions équivalentes, sachant que la syntaxe exacte peut
différer de celle de Pascal.
18
2 • Premiers pas
7) Les données composées sont le plus souvent organisées en objets, constitués
d’attributs ou champs. On accède à un champ particulier grâce à son nom, suivi
du nom de l’objet entre crochets. Par exemple, on considérera un tableau comme
un objet qui a un attribut longueur indiquant le nombre d’éléments contenus dans
l’objet. Pour spécifier le nombre d’éléments d’un tableau A, on écrit longueur[A].
Bien que les crochets s’utilisent tant pour l’indexation des tableaux que pour
l’accès aux attributs des objets, le contexte indique généralement comment on
doit les interpréter.
Une variable représentant un tableau ou un objet est considérée comme un pointeur vers les données représentant le tableau ou l’objet. Pour tous les champs f
d’un objet x, faire y ← x entraîne que f [y] = f [x]. Par ailleurs, si l’on fait maintenant f [x] ← 3, on a ensuite non seulement f [x] = 3 mais aussi f [y] = 3. Autrement
dit, x et y pointent vers le même objet après l’affectation y ← x.
Il arrive qu’un pointeur ne fasse référence à aucun objet. Nous lui donnons dans
ce cas la valeur particulière NIL.
8) Les paramètres sont passés à une procédure par valeur : la procédure appelée
reçoit son propre exemplaire des paramètres et, si elle modifie la valeur d’un paramètre, le changement n’est pas visible pour la routine appelante. Lorsque les
paramètres sont des objets, il y a copie du pointeur pointant vers les données représentant l’objet mais il n’y a pas copie des champs de l’objet. Par exemple, si
x est un paramètre d’une procédure appelée, l’affectation x ← y à l’intérieur de
la procédure appelée n’est pas visible pour la procédure appelante. En revanche,
l’affectation f [x] ← 3 est visible.
9) Les opérateurs booléens « et » et « ou » sont court-circuitants. Cela signifie que,
quand on évalue l’expression « x et y », on commence par évaluer x. Si x vaut
FAUX , alors l’expression globale ne peut pas être égale à VRAI et il est donc inutile
d’évaluer y. Si, en revanche, x vaut VRAI, alors il faut évaluer y pour déterminer la
valeur de l’expression globale. De même, dans l’expression « x ou y » on n’évalue
y que si x vaut FAUX. Les opérateurs court-circuitants nous permettent d’écrire des
expressions booléennes du genre « x fi NIL et f [x] = y » sans que nous ayons à
nous soucier de ce qui se passerait si on essayait d’évaluer f [x] quand x vaut NIL.
Exercices
2.1.1 À l’aide de la figure 2.2, illustrer l’action de T RI -I NSERTION sur le tableau
A = 31, 41, 59, 26, 41, 58.
2.1.2 Réécrire la procédure T RI -I NSERTION pour trier dans l’ordre non croissant et non dans
l’ordre non décroissant.
2.1.3 Considérez le problème de la recherche :
Entrée : Une suite de n nombres A = a1 , a2 , . . . , an et une valeur v.
Sortie : Un indice i tel que v = A[i], ou bien la valeur spéciale NIL si v ne figure pas dans A.
2.2
Analyse des algorithmes
19
Écrire du pseudo code pour recherche linéaire, qui parcourre la suite en cherchant v. En
utilisant un invariant de boucle, montrer la validité de l’algorithme. Vérifier que l’invariant
possède bien les trois propriétés requises.
2.1.4 On considère le problème consistant à additionner deux entiers en représentation binaire stockés sur n bits, rangés dans deux tableaux A et B à n éléments. La somme des deux
entiers doit être stockée sous forme binaire dans un tableau C à n + 1 éléments. Énoncer le
problème formellement et écrire du pseudo-code pour additionner les deux entiers.
c Dunod – La photocopie non autorisée est un délit
2.2 ANALYSE DES ALGORITHMES
Analyser un algorithme est devenu synonyme de prévoir les ressources nécessaires à
cet algorithme. Parfois, les ressources à prévoir sont la mémoire, la largeur de bande
d’une communication ou le processeur ; mais, le plus souvent, c’est le temps de calcul qui nous intéresse. En général, en analysant plusieurs algorithmes susceptibles de
résoudre le problème, on arrive aisément à identifier le plus efficace. Ce type d’analyse peut révéler plus d’un candidat viable, mais parvient généralement à éliminer les
algorithmes inférieurs.
Pour pouvoir analyser un algorithme, il faut avoir un modèle de la technologie
qui sera employée, notamment un modèle pour les ressources de cette technologie et
pour leurs coûts. Dans la majeure partie de ce livre, on supposera que l’on a un modèle de calcul générique basé sur une machine à accès aléatoire(RAM) à processeur
unique. On devra également avoir à l’esprit que nos algorithmes seront implémentés en tant que programmes informatiques. Dans le modèle RAM, les instructions
sont exécutées l’une après l’autre, sans opérations simultanées. Dans des chapitres
ultérieurs, cependant, nous aurons l’occasion d’examiner des modèles pour matériel
numérique.
À strictement parler, il faudrait définir précisément les instructions du modèle
RAM et leurs coûts. Cependant, cela serait pénible et n’apporterait pas grand chose
en matière de conception et d’analyse d’algorithme. Attention, toutefois, à ne pas enfreindre allègrement le modèle RAM. Par exemple, que se passerait-il si une RAM
avait une instruction de tri ? Alors, on pourrait trier avec une seule instruction. Une
telle RAM serait irréaliste, vu que les ordinateurs ne disposent pas de ce genre d’instructions. Nous devons donc nous laisser guider par le fonctionnement des ordinateurs concrets. Le modèle RAM contient les instructions que l’on trouve dans la
plupart des ordinateurs : arithmétique (addition, soustraction, multiplication, division, modulo, partie entière, partie entière supérieure, transfert de données (lecture,
stockage, copie) et instructions de contrôle (branchement conditionnel et inconditionnel, appel de sous-routine et sortie de sous-routine). Chacune de ces instructions a un
temps d’exécution constant.
Les types de données du modèle RAM sont le type entier et le type virgule flottante. Nous ne nous soucierons généralement pas de la précision dans cet ouvrage
20
2 • Premiers pas
mais, pour certaines applications, la précision est un élément crucial. Nous supposerons aussi qu’il existe une limite à la taille de chaque mot de données. Ainsi, quand
nous travaillerons avec des entrées de taille n, nous supposerons en principe que les
entiers sont représentés par c lg n bits pour une certaine constante c 1. Nous imposons que c 1 de façon que chaque mot puisse contenir la valeur n, ce qui nous
permettra d’indexer les divers éléments de l’entrée ; nous imposons aussi à c d’être
une constante de façon que la taille du mot n’augmente pas de manière arbitraire.
(Si la taille du mot pouvait croître de manière arbitraire, alors on pourrait stocker un
volume énorme de données dans un seul mot et manipuler ce volume de données en
un temps constant, ce qui est un scénario manifestement irréaliste.)
Les ordinateurs réels contiennent des instructions qui ne sont pas citées ici, et
ces instructions supplémentaires représentent une zone d’ombre du modèle RAM.
Par exemple, est-ce que l’exponentiation est une instruction à délai constant ? En
général, non ; elle exige plusieurs instructions pour calculer xy quand x et y sont des
réels. Dans certains cas particuliers, cependant, l’exponentiation est une opération à
délai constant. Maints ordinateurs disposent d’une instruction de « décalage vers la
gauche » qui, en temps constant, décale les bits d’un entier de k positions vers la
gauche. Pour la plupart des ordinateurs, décaler les bits d’un entier d’une position
vers la gauche revient à faire une multiplication par 2. Décaler les bits de k positions
vers la gauche revient donc à multiplier par 2k . Par conséquent, ce genre de machine
peut calculer 2k en une unique instruction à délai constant, et ce en décalant l’entier 1
de k positions vers la gauche, du moment que k n’est pas supérieur au nombre de bits
d’un mot machine. Nous ferons tous nos efforts pour éviter ce genre de zone d’ombre
du modèle RAM, mais nous considérerons le calcul de 2k comme une opération à
délai constant quand k est un entier positif suffisamment petit.
Dans le modèle RAM, nous n’essayons pas de refléter la hiérarchisation de la mémoire que l’on trouve généralement dans les ordinateurs modernes. Cela veut dire
que nous ne modélisons pas les caches, ni la mémoire virtuelle (qui est très souvent
mise en œuvre avec la pagination à la demande). Il existe plusieurs modèles de calcul
qui essaient de prendre en compte les effets de la hiérarchisation de la mémoire, effets parfois significatifs dans la pratique. Quelques rares problèmes, dans cet ouvrage,
examinent les effets de la hiérarchisation de la mémoire mais, dans l’ensemble, les
analyses faites dans ce livre n’en tiennent pas compte. Les modèles intégrant la hiérarchisation de la mémoire sont nettement plus compliqués que le modèle RAM, de
sorte que leur utilisation risque d’être délicate. Qui plus est, les analyses basées sur
le modèle RAM donnent généralement d’excellentes prévisions quant aux performances obtenues sur les machines réelles.
L’analyse d’un algorithme, même simple, dans le modèle RAM peut s’avérer complexe. On devra parfois faire appel à des outils mathématiques tels que l’algèbre combinatoire et la théorie des probabilités ; en outre, il faudra être capable de jongler avec
l’algèbre et de repérer les termes les plus significatifs dans une formule. Sachant que
le comportement d’un algorithme peut varier pour chaque entrée possible, il nous
2.2
Analyse des algorithmes
21
faut un moyen de résumer ce comportement en quelques formules simples et faciles
à comprendre.
Bien qu’on choisisse le plus souvent un seul modèle de machine pour analyser
un algorithme donné, on est cependant confronté à plusieurs possibilités quant à
la manière d’exprimer son analyse. On voudrait trouver un mode d’expression qui
soit simple à écrire et à manipuler, qui montre les caractéristiques importantes des
besoins d’un algorithme au niveau des ressources, et qui élimine les détails fastidieux.
a) Analyse du tri par insertion
La durée d’exécution de la procédure T RI -I NSERTION dépend de l’entrée : le tri d’un
millier de nombres prend plus de temps que le tri de trois nombres. De plus, T RI I NSERTION peut demander des temps différents pour trier deux entrées de même
taille, selon qu’elles sont déjà plus ou moins triées partiellement. En général, le temps
d’exécution d’un algorithme croît avec la taille de l’entrée ; on a donc pris l’habitude
d’exprimer le temps d’exécution d’un algorithme en fonction de la taille de son entrée. Reste à définir plus précisément ce que signifient « temps d’exécution » et « taille
de l’entrée ».
c Dunod – La photocopie non autorisée est un délit
Savoir ce que recouvre la notion de taille de l’entrée, cela dépend du problème
étudié. Pour de nombreux problèmes, tels le tri ou le calcul de transformées de Fourier
discrètes, la notion la plus naturelle est le nombre d’éléments constituant l’entrée, par
exemple la longueur n du tableau à trier. Pour beaucoup d’autres problèmes, comme
la multiplication de deux entiers, la meilleure mesure de la taille de l’entrée est le
nombre total de bits nécessaire à la représentation de l’entrée dans la notation binaire
habituelle. Parfois, il est plus approprié de décrire la taille de l’entrée avec deux
nombres au lieu d’un seul. Par exemple, si l’entrée d’un algorithme est un graphe,
on pourra décrire la taille de l’entrée par le nombre de sommets et le nombre d’arcs.
Pour chaque problème, nous indiquerons la mesure utilisée pour exprimer la taille de
l’entrée.
Le temps d’exécution d’un algorithme pour une entrée particulière est le nombre
d’opérations élémentaires, ou « étapes », exécutées. Il est commode de définir la notion d’étape de façon qu’elle soit le plus indépendante possible de la machine. Pour
le moment, nous adopterons le point de vue suivant. L’exécution de chaque ligne de
pseudo code demande un temps constant. Deux lignes différentes peuvent prendre
des temps différents, mais chaque exécution de la i-ème ligne prend un temps ci , ci
étant une constante. Ce point de vue est compatible avec le modèle RAM et reflète la
manière dont le pseudo code serait implémenté sur la plupart des ordinateurs réels.(4)
(4) Il faut noter ici quelques subtilités. Les étapes de calcul spécifiées en langage naturel sont souvent des
variantes d’une procédure qui demande plus qu’un volume de temps constant. Par exemple, nous verrons plus
loin dans ce livre que, quand nous disons « trier les points en fonction de leurs abscisses », cela demande
en fait plus qu’un temps constant. Notez aussi qu’une instruction qui appelle une sous-routine demande un
temps constant, alors même que le sous-programme, une fois invoqué, peut demander plus. Autrement dit, on
2 • Premiers pas
22
Dans l’étude qui suit, notre conception du temps d’exécution de T RI -I NSERTION
évoluera, pour remplacer une formule complexe utilisant tous les coûts d’instruction
ci par une notation bien plus simple, plus concise et plus facile à manipuler. Cette notation simplifiée nous aidera aussi à déterminer si un algorithme est plus performant
qu’un autre.
Nous commencerons par présenter la procédure T RI -I NSERTION avec le « coût »
temporel de chaque instruction et le nombre de fois que chaque instruction est exécutée. Pour tout j = 2, 3, . . . , n, où n = longueur[A], soit tj le nombre de fois que le
test de la boucle tant que, en ligne 5, est exécuté pour cette valeur de j. Quand une
boucle pour ou tant que se termine normalement (c’est-à-dire, suite au test effectué
dans l’en tête de la boucle), le test est exécuté une fois de plus que le corps de la
boucle. On suppose que les commentaires ne sont pas des instructions exécutables et
qu’ils consomment donc un temps nul.
T RI -I NSERTION (A)
1 pour j ← 2 à longueur[A]
2
faire clé ← A[j]
3
Insère A[j] dans la suite
triée A[1 . . j − 1].
4
i←j−1
5
tant que i > 0 et A[i] > clé
6
7
8
faire A[i + 1] ← A[i]
i←i−1
A[i + 1] ← clé
coût
c1
c2
fois
n
n−1
0
c4
c5
n−1
n− 1
c6
c7
c8
n
tj
j=2
n
(tj
j=2
n
j=2 (tj
n−1
− 1)
− 1)
Le temps d’exécution de l’algorithme est la somme des temps d’exécution de
chaque instruction exécutée ; une instruction qui demande ci étapes et qui est exécutée n fois compte pour ci n dans le temps d’exécution total(5) . Pour calculer T(n),
temps d’exécution de T RI -I NSERTION, on additionne les produits des colonnes coût
et fois, ce qui donne
n
n
T(n) = c1 n + c2 (n − 1) + c4 (n − 1) + c5
tj + c6
(tj − 1)
j=2
+ c7
j=2
n
(tj − 1) + c8 (n − 1) .
j=2
Même pour des entrées ayant la même taille, le temps d’exécution d’un algorithme peut dépendre de l’entrée particulière ayant cette taille. Par exemple, dans
sépare le processus d’appel du sous-programme (passage des paramètres, etc.) du processus d’ exécution du
sous-programme.
(5) Cette caractéristique n’est pas forcément valable pour une ressource comme la mémoire. Une instruction
qui référence m mots de mémoire et qui est exécutée n fois ne consomme pas nécessairement mn mots de
mémoire au total.
2.2
Analyse des algorithmes
23
T RI -I NSERTION, le cas le plus favorable est celui où le tableau est déjà trié. Pour tout
j = 2, 3, . . . , n, on trouve alors que A[i] clé en ligne 5 quand i a sa valeur initiale de
j − 1. Donc tj = 1 pour j = 2, 3, . . . , n, et le temps d’exécution associé au cas optimal
est
T(n) = c1 n + c2 (n − 1) + c4 (n − 1) + c5 (n − 1) + c8 (n − 1)
= (c1 + c2 + c4 + c5 + c8 )n − (c2 + c4 + c5 + c8 ) .
Ce temps d’exécution peut être exprimé sous la forme an + b, a et b étant des
constantes dépendant des coûts ci des instructions ; c’est donc une fonction linéaire
de n.
Si le tableau est trié dans l’ordre décroissant, alors on a le cas le plus défavorable. On doit comparer chaque élément A[j] avec chaque élément du sous-tableau
trié A[1 . . j − 1], et donc tj = j pour j = 2, 3, . . . , n. Si l’on remarque que
n
j=2
n(n + 1)
j=
−1
2
et
n
n(n − 1)
(j − 1) =
2
j=2
(voir l’annexe A pour ce genre de sommation), on trouve que, dans le cas le plus
défavorable, le temps d’exécution de T RI -I NSERTION est
n(n + 1)
−1
T(n) = c1 n + c2 (n − 1) + c4 (n − 1) + c5
2
n(n − 1) n(n − 1) + c7
+ c8 (n − 1)
+ c6
2 2
c
c6 c7 2
c5 c6 c7
5
+
+
n + c1 + c2 + c4 +
−
−
+ c8 n
=
2
2
2
2
2
2
− (c2 + c4 + c5 + c8 ) .
c Dunod – La photocopie non autorisée est un délit
Le temps d’exécution du cas le plus défavorable s’exprime donc sous la forme
an2 + bn + c, a, b et c étant ici aussi des constantes qui dépendent des coûts ci des
instructions ; c’est donc une fonction quadratique de n.
Généralement, et c’est le cas du tri par insertion, le temps d’exécution d’un algorithme est constant pour une entrée donnée ; encore que, dans des chapitres ultérieurs,
nous verrons quelques algorithmes « randomisés » intéressants dont le comportement
peut varier même pour une entrée fixée.
b) Analyse du cas le plus défavorable et du cas moyen
Dans notre analyse du tri par insertion, nous nous sommes intéressés aussi bien au
cas le plus favorable, celui où le tableau en entrée est déjà trié, qu’au cas le plus défavorable, celui où le tableau en entrée est trié en sens inverse. Dans la suite de ce
livre, cependant, nous aurons pour objectif général de déterminer le temps d’exécution du cas le plus défavorable, c’est-à-dire le temps d’exécution maximal pour une
quelconque entrée de taille n. Voici trois arguments en ce sens.
24
2 • Premiers pas
– Le temps d’exécution associé au cas le plus défavorable est une borne supérieure
du temps d’exécution associé à une entrée quelconque. Connaître cette valeur nous
permettra donc d’avoir la certitude que l’algorithme ne mettra jamais plus de temps
que cette limite. Ainsi, point besoin de faire de conjecture tarabiscotée sur le temps
d’exécution en espérant ne jamais rencontrer un cas encore pire.
– Pour certains algorithmes, le cas le plus défavorable survient assez souvent. Par
exemple, quand on recherche une information dans une base de données, si cette
information n’existe pas dans la base le cas le plus défavorable se présentera souvent pour l’algorithme de recherche.
– Il n’est pas rare que le « cas moyen » soit presque aussi mauvais que le cas le plus
défavorable. Supposons que l’on choisisse au hasard n nombres, auxquels on applique ensuite le tri par insertion. Combien de temps demande la détermination de
l’emplacement d’insertion de A[j] dans le sous-tableau A[1 . . j − 1] ? En moyenne,
la moitié des éléments de A[1 . . j − 1] sont inférieurs à A[j] et la moitié lui sont
supérieurs. Donc, en moyenne, on teste la moitié du sous-tableau A[1 . . j − 1],
auquel cas tj = j/2. Si l’on calcule alors le temps d’exécution global associé au
cas moyen, on voit que c’est une fonction quadratique de la taille de l’entrée, tout
comme le temps d’exécution du cas le plus défavorable.
Dans certains cas particuliers, nous nous intéresserons au temps d’exécution moyen, ou temps d’exécution attendu, d’un algorithme. Au chapitre 5, nous verrons la
technique de l’analyse probabiliste qui permet de déterminer le temps d’exécution
attendu. Toutefois, une difficulté concernant l’analyse du cas moyen est de savoir
ce qu’est une entrée « moyenne » pour un problème particulier. Souvent, nous supposerons que toutes les entrées ayant une taille donnée sont équiprobables. Cette
hypothèse n’est pas toujours vérifiée dans la pratique, mais nous pourrons parfois
employer un algorithme randomisé qui force des choix aléatoires afin de permettre
l’utilisation d’une analyse probabiliste.
c) Ordre de grandeur
Nous avons utilisé des hypothèses simplificatrices pour faciliter notre analyse de
la procédure T RI -I NSERTION. D’abord, nous avons ignoré le coût réel de chaque
instruction en employant les constantes ci pour représenter ces coûts. Ensuite, nous
avons observé que même ces constantes nous donnent plus de détails que nécessaire :
le temps d’exécution du cas le plus défavorable est an2 + bn + c, a, b et c étant des
constantes qui dépendent des coûts ci des instructions. Nous avons donc ignoré non
seulement les coûts réels des instructions, mais aussi les coûts abstraits ci .
Nous allons à présent utiliser une simplification supplémentaire. Ce qui nous intéresse vraiment, c’est le taux de croissance, ou ordre de grandeur, du temps d’exécution. On ne considérera donc que le terme dominant d’une formule (par exemple an2 ),
puisque les termes d’ordre inférieur sont moins significatifs pour n grand. On ignorera
également le coefficient constant du terme dominant, puisque les facteurs constants
sont moins importants que l’ordre de grandeur pour ce qui est de la détermination de
2.3
Conception des algorithmes
25
l’efficacité du calcul pour les entrées volumineuses. On écrira donc que le tri par insertion, par exemple, a dans le cas le plus défavorable un temps d’exécution de Q(n2 )
(prononcer « théta de n-deux »). Nous utiliserons la notation Q de façon informelle
dans ce chapitre ; elle sera définie de manière plus précise au chapitre 3.
On considère généralement qu’un algorithme est plus efficace qu’un autre si son
temps d’exécution du cas le plus défavorable a un ordre de grandeur inférieur. Compte
tenu des facteurs constants et des termes d’ordre inférieur, cette évaluation peut être
erronée pour des entrées de faible volume. En revanche, pour des entrées de taille
assez grande, un algorithme en Q(n2 ), par exemple, s’exécute plus rapidement, dans
le cas le plus défavorable, qu’un algorithme en Q(n3 ).
Exercices
2.2.1 Exprimer la fonction n3 /1000 − 100n2 − 100n + 3 à l’aide de la notation Q.
2.2.2 On considère le tri suivant de n nombres rangés dans un tableau A : on commence par
trouver le plus petit élément de A et on le permute avec A[1]. On trouve ensuite le deuxième
plus petit élément de A et on le permute avec A[2]. On continue de cette manière pour les n−1
premiers éléments de A. Écrire du pseudo code pour cet algorithme, connu sous le nom de tri
par sélection. Quel est l’invariant de boucle de cet algorithme ? Pourquoi suffit-il d’exécuter
l’algorithme pour les n − 1 premiers éléments ? Donner les temps d’exécution associés au cas
optimal et au cas le plus défavorable en utilisant la notation Q.
2.2.3 On considère une fois de plus la recherche linéaire (voir exercice 2.1.3). Combien
d’éléments de la séquence d’entrée doit-on tester en moyenne, si l’on suppose que l’élément
recherché a une probabilité égale d’être l’un quelconque des éléments du tableau ? Et dans
le cas le plus défavorable ? Quels sont les temps d’exécution du cas moyen et du cas le plus
défavorable, exprimés avec la notation Q ? Justifier les réponses.
c Dunod – La photocopie non autorisée est un délit
2.2.4 Comment modifier la plupart des algorithmes pour qu’ils aient un bon temps d’exécution dans le cas le plus favorable ?
2.3 CONCEPTION DES ALGORITHMES
Il existe de nombreuses façons de concevoir un algorithme. Le tri par insertion utilise
une approche incrémentale : après avoir trié le sous-tableau A[1 . . j − 1], on insère
l’élément A[j] au bon emplacement pour produire le sous-tableau trié A[1 . . j].
Cette section va présenter une autre approche de conception, baptisée « diviser
pour régner ». Nous utiliserons cette technique pour créer un algorithme de tri dont
le temps d’exécution du cas le plus défavorable sera très inférieur à celui du tri par
insertion. L’un des avantages des algorithmes diviser-pour-régner est que leurs temps
d’exécution sont souvent faciles à déterminer, via des techniques qui seront vues au
chapitre 4.
26
2 • Premiers pas
2.3.1 Méthode diviser-pour-régner
Nombre d’algorithmes utiles sont d’essence indexrécursivité récursive : pour résoudre le problème, ils s’appellent eux-mêmes, de manière récursive, une ou plusieurs fois pour traiter des sous-problèmes très similaires. Ces algorithmes suivent
généralement une approche diviser pour régner : ils séparent le problème en plusieurs
sous-problèmes semblables au problème initial mais de taille moindre, résolvent les
sous-problèmes de façon récursive, puis combinent toutes les solutions pour produire
la solution du problème original.
Le paradigme diviser-pour-régner implique trois étapes à chaque niveau de la récursivité :
Diviser : le problème en un certain nombre de sous-problèmes.
Régner : sur les sous-problèmes en les résolvant de manière récursive. Si la taille
d’un sous-problème est suffisamment réduite, on peut toutefois le résoudre directement.
Combiner : les solutions des sous-problèmes pour produire la solution du problème
originel.
L’algorithme du tri par fusion suit fidèlement la méthodologie diviser-pour-régner.
Intuitivement, il agit de la manière suivante :
Diviser : Diviser la suite de n éléments à trier en deux sous-suites de n/2 éléments
chacune.
Régner : Trier les deux sous-suites de manière récursive en utilisant le tri par fusion.
Combiner : Fusionner les deux sous-suites triées pour produire la réponse triée.
La récursivité s’arrête quand la séquence à trier a une longueur 1, auquel cas il n’y a
plus rien à faire puisqu’une suite de longueur 1 est déjà triée.
La clé de voûte de l’algorithme du tri par fusion, c’est la fusion de deux séquences
triées dans l’étape « combiner ». Pour faire cette fusion, nous utilisons une procédure
auxiliaire F USION(A, p, q, r), A étant un tableau et p, q et r étant des indices numérotant des éléments du tableau tels que p q < r. La procédure suppose que les
sous-tableaux A[p . . q] et A[q + 1 . . r] sont triés. Elle les fusionne pour en faire un
même sous-tableau trié, qui remplace le sous-tableau courant A[p . . r].
Notre procédure F USION a une durée Q(n), où n = r − p + 1 est le nombre d’éléments fusionnés. Voici comment elle fonctionne. En reprenant l’exemple du jeu de
cartes, supposez que l’on ait deux piles de cartes posées à l’endroit sur la table.
Chaque pile est triée de façon à ce que la carte la plus faible soit en haut. On veut
fusionner les deux piles pour obtenir une pile unique triée, dans laquelle les cartes seront à l’envers. L’étape fondamentale consiste à choisir la plus faible des deux cartes
occupant les sommets respectifs des deux piles, à la retirer de sa pile (ce qui a pour
effet d’exposer une nouvelle carte), puis à la placer à l’envers sur la pile résultante.
On répète cette étape jusqu’à épuisement de l’une des piles de départ, après quoi il
suffit de prendre la pile qui reste et de la placer à l’envers sur la pile résultante. Au
2.3
Conception des algorithmes
27
niveau du calcul, chaque étape fondamentale prend un temps constant, vu que l’on se
contente de comparer les deux cartes du haut. Comme l’on effectue au plus n étapes
fondamentales, la fusion prend une durée Q(n).
Le pseudo code suivant implémente la méthode que nous venons d’exposer, mais
avec une astuce supplémentaire qui évite de devoir, à chaque étape fondamentale,
vérifier si l’une des piles est vide. L’idée est de placer en bas de chaque pile une carte
sentinelle contenant une valeur spéciale qui nous permet de simplifier le code. Ici,
nous utiliserons ∞ comme valeur sentinelle ; ainsi, chaque fois qu’il y a apparition
d’une carte portant la valeur ∞, elle ne peut pas être la carte la plus faible sauf si
les deux piles exposent en même temps leurs cartes sentinelle. Mais alors, c’est que
toutes les autres cartes ont déjà été placées sur la pile de sortie. Comme nous savons
à l’avance qu’il y aura exactement r − p + 1 cartes sur la pile de sortie, nous pourrons
arrêter lorsque nous aurons effectué ce nombre d’étapes.
c Dunod – La photocopie non autorisée est un délit
F USION(A, p, q, r)
1 n1 ← q − p + 1
2 n2 ← r − q
3 créer tableaux L[1 . . n1 + 1] et R[1 . . n2 + 1]
4 pour i ← 1 à n1
5
faire L[i] ← A[p + i − 1]
6 pour j ← 1 à n2
7
faire R[j] ← A[q + j]
8 L[n1 + 1] ← ∞
9 R[n2 + 1] ← ∞
10 i ← 1
11 j ← 1
12 pour k ← p à r
13
faire si L[i] R[j]
14
alors A[k] ← L[i]
15
i←i+1
16
sinon A[k] ← R[j]
17
j←j+1
Voici le détail du fonctionnement de la procédure F USION. La ligne 1 calcule la
longueur n1 du sous-tableau A[p..q] et la ligne 2 calcule la longueur n2 du soustableau A[q + 1..r]. On crée des tableaux L et R de longueurs n1 + 1 et n2 + 1, respectivement, en ligne 3. La boucle pour des lignes 4–5 copie le sous-tableau A[p . . q]
dans L[1 . . n1 ] et la boucle pour des lignes 6–7 copie le sous-tableau A[q + 1 . . r]
dans R[1 . . n2 ]. Les lignes 8–9 placent les sentinelles aux extrémités des tableaux L
et R. Les lignes 10–17, illustrées sur la figure 2.3, effectuent les r − p + 1 étapes
fondamentales en conservant l’invariant de boucle que voici :
Au début de chaque itération de la boucle pour des lignes 12–17, le soustableau A[p . . k − 1] contient les k − p plus petits éléments de L[1 . . n1 + 1] et
2 • Premiers pas
28
8
9
A … 2
k
L
10 11 12 13 14 15 16 17
4
5
7
1
2
3
8
6 …
9
A … 1
1
2
3
4
5
1
2
3
4
2
i
4
5
7 ∞
R 1
j
2
3
6 ∞
5
L
10 11 12 13 14 15 16 17
4
k
5
8
9
L
5
k
4
5
7
1
2
3
2
3
4
5
1
2
3
4
5
7 ∞
R 1
2
j
3
6 ∞
2
3
4
i
5
7 ∞
1
2
3
R 1
2
j
3
4
8
9
10 11 12 13 14 15 16 17
9
A … 1
5
6 ∞
L
2
2
4
5
7
k
2
1
2
3
4
5
1
2
3
4
2
4
i
5
7 ∞
R 1
2
3
6 ∞
j
L
9
1
2
3
5
2
4
5
4
3
7 ∞
i
4
2
3
6 …
8
9
L
6 …
2
3
4
i
5
7 ∞
8
9
10 11 12 13 14 15 16 17
A … 1
5
1
2
3
R 1
2
3
j
4
5
6 ∞
L
2
2
3
4
2
k
3
6 …
1
2
3
4
5
1
2
3
4
2
4
5
i
7 ∞
R 1
2
3
6 ∞
j
5
(f)
5
3
k
8
6 …
1
2
3
R 1
2
3
4
9
A … 1
5
6 ∞
j
(g)
A … 1
3
1
10 11 12 13 14 15 16 17
2
2
2
(e)
8
1
(d)
A … 1 2
A … 1 2
5
10 11 12 13 14 15 16 17
(c)
L
6 …
4
8
6 …
1
1
k
3
(b)
2
3
2
1
10 11 12 13 14 15 16 17
2
1
2
i
(a)
A … 1
7
L
1
2
3
2
4
5
10 11 12 13 14 15 16 17
2
2
4
5
7 ∞
i
3
4
5 6
6 …
k
1
2
3
R 1
2
3
4
5
6 ∞
j
(h)
10 11 12 13 14 15 16 17
2
2
3
4
5 6
7 …
k
1
2
3
4
5
1
2
3
4
2
4
5
7 ∞
i
R 1
2
3
6 ∞
j
5
(i)
Figure 2.3 Le fonctionnement des lignes 10–17 dans l’appel F USION(A, 9, 12, 16), quand le soustableau A[9 . . 16] contient la séquence 2, 4, 5, 7, 1, 2, 3, 6. Après copie et insertion des sentinelles,
le tableau L contient 2, 4, 5, 7, ∞ et le tableau R contient 1, 2, 3, 6, ∞. Les cases en gris
clair dans A contiennent leurs valeurs définitives, alors que les cases en gris clair dans L et R
contiennent des valeurs qui n’ont pas encore été copiées dans A. Prises ensemble, les cases en
gris clair contiennent à tout moment les valeurs qui étaient à l’origine dans A[9 . . 16], avec en plus
les deux sentinelles. Les cases en gris foncé dans A contiennent des valeurs qui seront remplacées
par des duplications ultérieures, alors que les cases en gris foncé dans L et R contiennent des
valeurs qui ont été déjà copiées dans A. (a)–(h) Les tableaux A, L et R, avec leurs indices respectifs
k, i et j avant chaque itération de la boucle des lignes 12–17. (i) Les tableaux et les indices à la
fin. À ce stade, le sous-tableau dans A[9 . . 16] est trié et les deux sentinelles dans L et R sont les
seuls éléments de ces tableaux qui n’ont pas été copiés dans A.
2.3
Conception des algorithmes
29
R[1 . . n2 + 1], en ordre trié. En outre, L[i] et R[j] sont les plus petits éléments
de leurs tableaux à ne pas avoir été copiés dans A.
Il faut montrer que cet invariant est vrai avant la première itération de la boucle
pour des lignes 12–17, que chaque itération de la boucle conserve l’invariant, et enfin
que l’invariant fournit une propriété utile pour prouver la conformité de la procédure
quand la boucle se termine.
Initialisation : Avant la première itération de la boucle, on a k = p, de sorte que le
sous-tableau A[p . . k − 1] est vide. Ce sous-tableau vide contient les k − p = 0
plus petits éléments de L et R ; et, comme i = j = 1, L[i] et R[j] sont les plus petits
éléments de leurs tableaux à ne pas avoir été copiés dans A.
Conservation : Pour montrer que chaque itération conserve l’invariant, supposons
en premier lieu que L[i] R[j]. Alors L[i] est le plus petit élément qui n’a pas
encore été copié dans A. Comme A[p . . k−1] contient les k−p plus petits éléments,
après que la ligne 14 a copié L[i] dans A[k], le sous-tableau A[p . . k] contient les
k−p+1 plus petits éléments. Incrémenter k (dans l’actualisation de la boucle pour)
et i (en ligne 15) recrée l’invariant pour l’itération suivante. Si l’on a L[i] > R[j],
alors les lignes 16–17 font l’action idoine pour conserver l’invariant.
Terminaison : À la fin de la boucle, k = r + 1. D’après l’invariant, le sous-tableau
A[p . . k − 1], qui est A[p . . r], contient les k − p = r − p + 1 plus petits éléments
de L[1 . . n1 + 1] et R[1 . . n2 + 1], dans l’ordre trié. Les tableaux L et R, à eux deux,
contiennent n1 + n2 + 2 = r − p + 3 éléments. Tous les éléments, sauf les deux plus
forts, ont été copiés dans A, et ces deux plus gros éléments ne sont autres que les
sentinelles.
c Dunod – La photocopie non autorisée est un délit
Pour voir que la procédure F USION s’exécute en Q(n) temps, avec n = r − p + 1,
observez que chacune des lignes 1–3 et 8–11 prend un temps constant, que les
boucles pour des lignes 4–7 prennent Q(n1 + n2 ) = Q(n) temps, (6) et qu’il y a
n itérations de la boucle pour des lignes 12–17, chacune d’elles prenant un temps
constant.
Nous pouvons maintenant employer la procédure F USION comme sous-routine de
l’algorithme du tri par fusion. La procédure T RI -F USION(A, p, r) trie les éléments
du sous-tableau A[p . . r]. Si p r, le sous-tableau a au plus un seul élément et il est
donc déjà trié. Sinon, l’étape diviser se contente de calculer un indice q qui partitionne
A[p . . r] en deux sous-tableaux : A[p . . q], contenant n/2 éléments, et A[q + 1 . . r],
contenant n/2 éléments. (7)
(6) Nous verrons au chapitre 3 comment interpréter de manière formelle les équations contenant
la notation Q.
(7) L’expression x désigne le plus petit entier supérieur ou égal à x, alors que x désigne le plus grand
entier inférieur ou égal à x. Ces notations sont définies au chapitre 3. Le moyen le plus simple de vérifier que
donner à q la valeur (p + r)/2 produit des sous-tableaux A[p . . q] et A[q + 1 . . r] de tailles respectives n/2
et n/2, c’est d’examiner les quatre cas possibles selon que chacune des valeurs p et r est impaire ou paire.
2 • Premiers pas
30
T RI -F USION(A, p, r)
1 si p < r
2
alors q ← (p + r)/2
3
T RI -F USION(A, p, q)
4
T RI -F USION(A, q + 1, r)
5
F USION(A, p, q, r)
Pour trier toute la séquence A = A[1], A[2], . . . , A[n], on fait l’appel initial
T RI -F USION(A, 1, longueur[A]) (ici aussi, longueur[A] = n). La figure 2.4 illustre
le fonctionnement de la procédure, du bas vers le haut, quand n est une puissance
de 2. L’algorithme consiste à fusionner des paires de séquences à 1 élément pour
former des séquences triées de longueur 2, à fusionner des paires de séquences de
longueur 2 pour former des séquences triées de longueur 4, etc. jusqu’à qu’il y ait
fusion de deux séquences de longueur n/2 pour former la séquence triée définitive de
longueur n.
séquence triée
1
2
2
3
4
5
6
7
1
2
3
fusion
2
4
5
7
fusion
2
fusion
5
4
fusion
5
7
1
fusion
2
6
4
3
2
fusion
7
1
6
fusion
3
2
6
séquence initiale
Figure 2.4 Le fonctionnement du tri par fusion sur le tableau A = 5, 2, 4, 7, 1, 3, 2, 6. Les longueurs des séquences triées en cours de fusion augmentent à mesure que l’algorithme remonte
du bas vers le haut.
2.3.2 Analyse des algorithmes diviser-pour-régner
Lorsqu’un algorithme contient un appel récursif à lui même, son temps d’exécution
peut souvent être décrit par une équation de récurrence, ou récurrence, qui décrit le
temps d’exécution global pour un problème de taille n à partir du temps d’exécution
pour des entrées de taille moindre. On peut alors se servir d’outils mathématiques
2.3
Conception des algorithmes
31
pour résoudre la récurrence et trouver des bornes pour les performances de l’algorithme.
Une récurrence pour le temps d’exécution d’un algorithme diviser-pour-régner
s’appuie sur les trois étapes du paradigme de base. Comme précédemment, soit T(n)
le temps d’exécution d’un problème de taille n. Si la taille du problème est suffisamment petite, disons n c pour une certaine constante c, la solution directe prend
un temps constant que l’on écrit Q(1). Supposons que l’on divise le problème en a
sous-problèmes, la taille de chacun étant 1/b de la taille du problème initial. (Pour
le tri par fusion, tant a que b valent 2, mais nous verrons beaucoup d’algorithmes
diviser-pour-régner dans lesquels a fi b.) Si l’on prend un temps D(n) pour diviser
le problème en sous-problèmes et un temps C(n) pour construire la solution finale à
partir des solutions aux sous-problèmes, on obtient la récurrence
Q(1)
si n c ,
T(n) =
aT(n/b) + D(n) + C(n) sinon .
Au chapitre 4, nous verrons comment résoudre des récurrences communes ayant cette
forme.
a) Analyse du tri par fusion
Bien que le pseudo-code de T RI -F USION s’exécute correctement quand le nombre
d’éléments n’est pas pair, notre analyse fondée sur la récurrence sera simplifiée si
nous supposons que la taille du problème initial est une puissance de deux. Chaque
étape diviser génère alors deux sous-séquences de taille n/2 exactement. Au chapitre 4, nous verrons que cette supposition n’affecte pas l’ordre de grandeur de la
solution de la récurrence.
Nous raisonnerons comme suit pour mettre en œuvre la récurrence pour T(n),
temps d’exécution du cas le plus défavorable du tri par fusion de n nombres. Le
tri par fusion d’un seul élément prend un temps constant ; avec n > 1 éléments, on
segmente le temps d’exécution de la manière suivante.
c Dunod – La photocopie non autorisée est un délit
Diviser : L’étape diviser se contente de calculer le milieu du sous-tableau, ce qui
consomme un temps constant. Donc D(n) = Q(1).
Régner : On résout récursivement deux sous-problèmes, chacun ayant la taille n/2,
ce qui contribue pour 2T(n/2) au temps d’exécution.
Combiner : Nous avons déjà noté que la procédure F USION sur un sous-tableau à n
éléments prenait un temps Q(n), de sorte que C(n) = Q(n).
Quand on ajoute les fonctions D(n) et C(n) pour l’analyse du tri par fusion, on
ajoute une fonction qui est Q(n) et une fonction qui est Q(1). Cette somme est une
fonction linéaire de n, à savoir Q(n). L’ajouter au terme 2T(n/2) de l’étape régner
donne la récurrence pour T(n), temps d’exécution du tri par fusion dans le cas le plus
défavorable :
Q(1)
si n = 1 ,
T(n) =
(2.1)
2T(n/2) + Q(n) si n > 1 .
32
2 • Premiers pas
Au chapitre 4, nous verrons le « théorème général » grâce auquel on peut montrer
que T(n) est Q(n lg n), où lg n désigne log2 n. Comme la fonction logarithme croit
plus lentement que n’importe quelle fonction linéaire, pour des entrées suffisamment
grandes, le tri par fusion, avec son temps d’exécution Q(n lg n), est plus efficace que le
tri par insertion dont le temps d’exécution vaut Q(n2 ) dans le cas le plus défavorable.
On n’a pas besoin du théorème général pour comprendre intuitivement pourquoi
la solution de la récurrence (2.1) est T(n) = Q(n lg n). Réécrivons la récurrence (2.1)
comme suit
c
si n = 1 ,
T(n) =
(2.2)
2T(n/2) + cn si n > 1 ,
la constante c représentant le temps requis pour résoudre des problèmes de taille 1,
ainsi que le temps par élément de tableau des étapes diviser et combiner. (8)
La figure 2.5 montre comment on peut résoudre la récurrence (2.2). Pour des raisons de commodité, on suppose que n est une puissance exacte de 2. La partie (a)
de la figure montre T(n) qui, en partie (b), a été développé pour devenir un arbre
équivalent représentant la récurrence. Le terme cn est la racine (le coût au niveau supérieur de récursivité), et les deux sous-arbres de la racine sont les deux récurrences
plus petites T(n/2). La partie (c) montre le processus une étape plus loin, après expansion de T(n/2). Le coût de chacun des deux sous-nœuds, au deuxième niveau de
récursivité, est cn/2. On continue de développer chaque nœud de l’arbre en le divisant en ses parties constituantes telles que déterminées par la récurrence, jusqu’à ce
que les tailles de problème tombent à 1, chacune ayant alors un coût c. La partie (d)
montre l’arbre résultant.
Ensuite, on cumule les coûts sur chaque niveau. Le niveau supérieur a un coût
total cn, le niveau suivant a un coût total c(n/2) + c(n/2) = cn, le niveau suivant a un
coût total c(n/4) + c(n/4) + c(n/4) + c(n/4) = cn, etc. Plus généralement le niveau i
au-dessous de la racine a 2i nœuds, dont chacun contribue pour un coût c(n/2i ), de
sorte que ce i-ème niveau a un coût total 2i c(n/2i ) = cn. Au niveau le plus bas, il y a
n nœuds dont chacun contribue pour un coût de c, ce qui donne un coût total de cn.
Le nombre total de niveaux de cet « arbre de récursivité », tel qu’indiqué sur la
figure 2.5, est lg n+1. Ce fait se laisse facilement vérifier par un raisonnement inductif
informel. Le cas de base se produit pour n = 1, auquel cas il n’y a qu’un seul niveau.
Comme lg 1 = 0, on voit que lg n + 1 donne le nombre correct de niveaux. Supposons
maintenant, comme hypothèse de récurrence, que le nombre de niveaux d’un arbre
de récursivité à 2i nœuds soit lg 2i + 1 = i + 1 (car, pour une valeur quelconque
de i, on a lg 2i = i). Comme on suppose que la taille originelle de l’entrée est une
puissance de 2, la taille à considérer pour l’entrée suivante est 2i+1 . Un arbre à 2i+1
(8) Il est très improbable que la même constante représente exactement et le temps de résolution des problèmes de taille 1 et le temps par élément de tableau des étapes diviser et combiner. On peut contourner cette
difficulté en prenant pour c le plus grand de ces temps et en supposant que notre récurrence donne une borne
supérieure du temps d’exécution, ou bien en prenant pour c le plus petit de ces temps et en partant du principe
que notre récurrence donne une borne inférieure du temps d’exécution. Les deux bornes sont de l’ordre de
n lg n et, à elles deux, donnent un temps d’exécution en Q(n lg n).
2.3
Conception des algorithmes
T(n)
33
cn
T(n/2)
cn
T(n/2)
T(n/4)
(a)
cn/2
cn/2
T(n/4)
(b)
T(n/4)
T(n/4)
(c)
cn
cn
cn/2
cn/2
cn
lg n
cn/4
cn/4
cn/4
cn
…
cn/4
c Dunod – La photocopie non autorisée est un délit
c
c
c
c
c
…
c
c
cn
n
(d)
Total: cn lg n + cn
Figure 2.5 La construction d’un arbre de récursivité pour la récurrence T(n) = 2T(n/2) + cn. La
partie (a) montre T(n), progressivement développé en (b)–(d) pour former l’arbre de récursivité.
L’arbre complet, en partie (d), a lg n+1 niveaux (c’est-à-dire, il a une hauteur lg n comme indiquée),
sachant que chaque niveau contribue pour un coût total de cn. Le coût global est donc cn lg n+cn,
c’est à dire Q(n lg n).
34
2 • Premiers pas
nœuds ayant un niveau de plus qu’un arbre à 2i nœuds, le nombre total de niveaux
est donc (i + 1) + 1 = lg 2i+1 + 1.
Pour calculer le coût total représenté par la récurrence (2.2), il suffit d’additionner
les coûts de tous les niveaux. Il y a lg n + 1 niveaux, chacun coûtant cn, ce qui donne
un coût global de cn(lg n + 1) = cn lg n + cn. En ignorant le terme d’ordre inférieur et
la constante c, on arrive au résultat souhaité qui est Q(n lg n).
Exercices
2.3.1 En s’inspirant de la figure 2.4, illustrer le fonctionnement du tri par fusion sur le tableau A = 3, 41, 52, 26, 38, 57, 9, 49.
2.3.2 Réécrire la procédure F USION de telle sorte qu’elle n’emploie pas de sentinelles mais
qu’à la place elle s’arrête quand l’un des deux tableaux L et R a eu tous ses éléments copiés
dans A, en copiant alors le reste de l’autre tableau dans A.
2.3.3 Utiliser l’induction mathématique pour montrer que, lorsque n est une puissance exacte
de 2, la solution de la récurrence
2
si n = 2 ,
T(n) =
2T(n/2) + n si n = 2k , pour k > 1
est T(n) = n lg n.
2.3.4 Le tri par insertion peut être exprimé sous la forme d’une procédure récursive de la
manière suivante. Pour trier A[1 . . n], on trie récursivement A[1 . . n − 1] puis on insère A[n]
dans le tableau trié A[1 . . n − 1]. Écrire une récurrence pour le temps d’exécution de cette
version récursive du tri par insertion.
2.3.5 En reprenant le problème de la recherche (voir exercice 2.1.3), observez que, si la
séquence A est triée, on peut comparer le milieu de la séquence avec v et supprimer la moitié
de la séquence pour la suite des opérations. La recherche dichotomique est un algorithme qui
répète cette procédure, en divisant par deux à chaque fois la taille de la partie restante de la
séquence. Écrire le pseudo code, itératif ou récursif, de la recherche dichotomique. Expliquer
pourquoi le temps d’exécution de la recherche dichotomique, dans le cas le plus défavorable,
est Q(lg n).
2.3.6 On observe que la boucle tant que des lignes 5 – 7 de la procédure T RI -I NSERTION
de la section 2.1 utilise une recherche linéaire pour parcourir (à rebours) le sous-tableau trié
A[1 . . j − 1]. Est-il possible d’utiliser à la place une recherche dichotomique (voir l’exercice 2.3.7) pour améliorer le temps d’exécution global du tri par insertion, dans le cas le plus
défavorable, de façon qu’il devienne Q(n lg n) ?
2.3.7 Décrire un algorithme en Q(n lg n) qui, étant donnés un ensemble S de n entiers
et un autre entier x, détermine s’il existe ou non deux éléments de S dont la somme vaut
exactement x.
Problèmes
35
PROBLÈMES
2.1. Tri par insertion sur petits tableaux dans le cadre du tri par fusion
Bien que, dans le pire des cas, le tri par fusion s’exécute en Q(n lg n) et le tri par
insertion en Q(n2 ), les facteurs constants du tri par insertion le rendent plus rapide
pour n petit. Il est donc logique d’utiliser le tri par insertion à l’intérieur du tri par
fusion lorsque les sous-problèmes deviennent suffisamment petits. On va étudier la
modification suivante du tri par fusion : n/k sous-listes de longueur k sont triées via
tri par insertion, puis fusionnées à l’aide du mécanisme de fusion classique. k est une
valeur à déterminer.
a. Montrer que les n/k sous-listes, chacune de longueur k, peuvent être triées via tri
par insertion avec un temps Q(nk) dans le cas le plus défavorable.
b. Montrer que les sous-listes peuvent être fusionnées en Q(n lg(n/k)) dans le cas le
plus défavorable.
c. Sachant que l’algorithme modifié s’exécute en Q(nk + n lg(n/k)) dans le cas le
plus défavorable, quelle est la plus grande valeur asymptotique (notation Q) de
k, en tant que fonction de n, pour laquelle l’algorithme modifié a le même temps
d’exécution asymptotique que le tri par fusion classique ?
d. Comment doit-on choisir k en pratique ?
2.2. Conformité du tri à bulles
Le tri à bulles est un algorithme de tri très populaire. Son fonctionnement s’appuie
sur des permutations répétées d’éléments contigus qui ne sont pas dans le bon ordre.
c Dunod – La photocopie non autorisée est un délit
T RI - BULLES(A)
1 pour i ← 1 à longueur[A]
2
faire pour j ← length[A] decr jusqu’à i + 1
3
faire si A[j] < A[j − 1]
4
alors permuter A[j] ↔ A[j − 1]
a. Soit A le résultat de T RI - BULLES(A). Pour prouver la conformité de T RI - BULLES,
il faut montrer qu’il se termine et que
A [1] A [2] · · · A [n] ,
(2.3)
avec n = longueur[A]. Que faut-il prouver d’autre pour démontrer que T RI - BULLES
trie réellement ?
Les deux parties suivantes vont montrer l’inégalité (2.3).
b. Définir de manière précise un invariant pour la boucle pour des lignes 2–4, puis
montrer que cet invariant est vérifié. La démonstration doit employer la structure
d’invariant de boucle exposée dans ce chapitre.
2 • Premiers pas
36
c. En utilisant la condition de finalisation de l’invariant démontrée en (b), définir
un invariant pour la boucle pour des lignes 1–4 qui permette de prouver l’inégalité (2.3). La démonstration doit employer la structure d’invariant de boucle
exposée dans ce chapitre.
d. Quel est le temps d’exécution du tri à bulles dans le cas le plus défavorable ?
Quelles sont ses performances, comparées au temps d’exécution du tri par insertion ?
2.3. Conformité de la règle de Horner
Le code suivant implémente la règle de Horner relative à l’évaluation d’un polynôme
P(x) =
n
ak xk = a0 + x(a1 + x(a2 + · · · + x(an−1 + xan ) · · · )) ,
k=0
étant donnés les coefficients a0 , a1 , . . . , an et une valeur de x :
1
2
3
4
5
y←0
i←n
tant que i 0
faire y ← ai + x·y
i←i−1
a. Quel est le temps d’exécution asymptotique de ce fragment de code ?
b. Écrire du pseudo code qui implémente l’algorithme naïf d’évaluation polynomiale,
lequel calcule chaque terme du polynôme ex nihilo. Quel est le temps d’exécution
de cet algorithme ? Quelles sont ses performances, comparées à celles de la règle
de Horner ?
c. Prouver que ce qui suit est un invariant pour la boucle tant que des lignes 3 –5.
Au début de chaque itération de la boucle tant que des lignes 3–5,
y=
n−(i+1)
ak+i+1 xk .
k=0
On considérera qu’une sommation sans termes est égale à 0. La démonstration
doit se conformer à la structure
d’invariant de boucle exposée dans ce chapitre et
montrer que, à la fin, on a y = nk=0 ak xk .
d. Conclure en démontrant que le fragment de code donné évalue correctement un
polynôme caractérisé par les coefficients a0 , a1 , . . . , an .
2.4. Inversions
Soit A[1 . . n] un tableau de n nombres distincts. Si i < j et A[i] > A[j], on dit que le
couple (i, j) est une inversion de A.
a. Donner les cinq inversions du tableau 2, 3, 8, 6, 1.
Notes
37
b. Quel est le tableau dont les éléments appartiennent à l’ensemble {1, 2, . . . , n} qui
a le plus d’inversions ? Combien en possède-t-il ?
c. Quelle est la relation entre le temps d’exécution du tri par insertion et le nombre
d’inversion du tableau en entrée ? Justifier la réponse.
d. Donner un algorithme qui détermine en un temps Q(n lg n), dans le cas le plus
défavorable, le nombre d’inversions présentes dans une permutation quelconque
de n éléments. (Conseil : Modifier le tri par fusion.)
NOTES
c Dunod – La photocopie non autorisée est un délit
En 1968, Knuth publia le premier des trois volumes ayant le titre général The Art of Computer Programming [182, 183, 185]. Le premier volume introduisait l’étude moderne des
algorithmes informatiques en insistant sur l’analyse du temps d’exécution, et la série complète reste une référence attrayante et utile pour nombre de sujets présentés ici. Selon Knuth,
le mot « algorithme » vient de « al-Khowârizmî » qui était un mathématicien Persan du neuvième siècle.
Aho, Hopcroft et Ullman [5] ont plaidé en faveur de l’analyse asymptotique des algorithmes comme moyen de comparer les performances relatives. Ils ont également popularisé
l’utilisation des relations de récurrence pour décrire les temps d’exécution des algorithmes
récursifs.
Knuth [185] offre une étude encyclopédique de nombreux algorithmes de tri. Sa comparaison des algorithmes de tri (page 381) contient des analyses dénombrant très exactement les
étapes, comme celle que nous avons effectuée ici pour le tri par insertion. L’étude par Knuth
du tri par insertion englobe plusieurs variantes de l’algorithme. La plus importante est le tri
de Shell, dû à D. L. Shell, qui utilise le tri par insertion sur des sous-séquences périodiques
de l’entrée pour produire un algorithme de tri plus rapide.
Knuth décrit également le tri par fusion. Il mentionne qu’une machine mécanique capable
de fusionner deux jeux de cartes perforées en une seule passe fut inventée en 1938. Il semblerait que J. von Neumann, l’un des pionniers de l’informatique, ait écrit un programme de
tri par fusion pour l’ordinateur EDVAC en 1945.
Gries [133] contient l’historique des essais de démonstration de la conformité des programmes ; il attribue à P. Naur le premier article paru sur le sujet. Gries attribue la paternité
des invariants de boucle à R. W. Floyd. Le manuel de Mitchell [222] présente des travaux
plus récents en matière de démonstration de la justesse des programmes.
Chapitre 3
c Dunod – La photocopie non autorisée est un délit
Croissance des fonctions
L’ordre de grandeur du temps d’exécution d’un algorithme, défini au chapitre 2,
donne une caractérisation simple de l’efficacité de l’algorithme et permet également
de comparer les performances relatives d’algorithmes servant à faire le même travail.
Quand la taille de l’entrée n devient suffisamment grande, le tri par fusion, avec son
temps d’exécution en Q(n lg n) pour le cas le plus défavorable, l’emporte sur le tri
par insertion, dont le temps d’exécution dans le cas le plus défavorable est en Q(n2 ).
Bien qu’il soit parfois possible de déterminer le temps d’exécution exact d’un algorithme, comme nous l’avons fait pour le tri par insertion au chapitre 2, cette précision
supplémentaire ne vaut généralement pas la peine d’être calculée. Pour des entrées
suffisamment grandes, les effets des constantes multiplicatives et des termes d’ordre
inférieur d’un temps d’exécution exact sont négligeables par rapport aux effets de la
taille de l’entrée.
Quand on prend des entrées suffisamment grandes pour que seul compte réellement
l’ordre de grandeur du temps d’exécution, on étudie les performances asymptotiques
des algorithmes. En clair, on étudie la façon dont augmente à la limite le temps d’exécution d’un algorithme quand la taille de l’entrée augmente indéfiniment. En général, un algorithme plus efficace asymptotiquement qu’un autre constitue le meilleur
choix, sauf pour les entrées très petites.
Ce chapitre va présenter plusieurs méthodes classiques pour la simplification de
l’analyse asymptotique des algorithmes. La première section définira plusieurs types
de « notation asymptotique », dont nous avons déjà vu un exemple avec la notation
Q. Seront ensuite présentées diverses conventions de notation utilisées tout au long
de ce livre. Le chapitre se terminera par une révision du comportement de fonctions
qui reviennent fréquemment dans l’analyse des algorithmes.
3 • Croissance des fonctions
40
3.1 NOTATION ASYMPTOTIQUE
Les notations que nous utiliserons pour décrire le temps d’exécution asymptotique
d’un algorithme sont définies en termes de fonctions dont le domaine de définition est
l’ensemble des entiers naturels N = {0, 1, 2, . . .}. De telles notations sont pratiques
pour décrire la fonction T(n) donnant le temps d’exécution du cas le plus défavorable,
qui n’est généralement définie que sur des entrées de taille entière. Cependant, il est
parfois avantageux d’étendre abusivement la notation asymptotique de diverses manières. Par exemple, la notation peut être facilement étendue au domaine des nombres
réels, ou, inversement, réduite à un sous-ensemble des entiers naturels. Il est donc important de comprendre la signification précise de la notation, de sorte que, même si
l’on en abuse, on n’en mésuse pas. Cette section va définir les notations asymptotiques fondamentales et présenter certains abus fréquents.
a) Notation Q
Au chapitre 2, nous avons trouvé que le temps d’exécution, dans le cas le plus défavorable, du tri par insertion est T(n) = Q(n2 ). Définissons le sens de cette notation.
Pour une fonction donnée g(n), on note Q(g(n)) l’ensemble de fonctions suivant :
Q(g(n)) = {f (n) : il existe des constantes positives c1 , c2 et n0 telles que
0 c1 g(n) f (n) c2 g(n) pour tout n n0 } .(1)
Une fonction f (n) appartient à l’ensemble Q(g(n)) s’il existe des constantes positives c1 et c2 telles que f (n) puisse être « prise en sandwich » entre c1 g(n) et
c2 g(n), pour n assez grand. Comme Q(g(n)) est un ensemble, on pourrait écrire
« f (n) ∈ Q(g(n)) » pour indiquer que f (n) est un membre de Q(g(n)). En fait, on
préfère écrire « f (n) = Q(g(n)) ». Cet emploi abusif de l’égalité pour représenter
l’appartenance à un ensemble peut dérouter au début, mais nous verrons plus loin
dans cette section que cela offre des avantages.
La figure 3.1(a) donne une représentation intuitive de fonctions f (n) et g(n) vérifiant f (n) = Q(g(n)). Pour toutes les valeurs de n situées à droite de n0 , la valeur de
f (n) est supérieure ou égale à c1 g(n) et inférieure ou égale à c2 g(n). Autrement dit,
pour tout n n0 , la fonction f (n) est égale à g(n) à un facteur constant près. On dit
que g(n) est une borne asymptotiquement approchée de f (n).
La définition de Q(g(n)) impose que chaque membre f (n) ∈ Q(g(n)) soit asymptotiquement positif, c’est-à-dire que f (n) soit toujours positive pour n suffisamment
grand. (Une fonction asymptotiquement strictement positive est une fonction qui est
positive pour n suffisamment grand.) En conséquence, la fonction g(n) elle-même doit
être asymptotiquement positive ; sinon, l’ensemble Q(g(n)) serait vide. On supposera
donc que toute fonction utilisée dans la notation Q est asymptotiquement positive.
Cette hypothèse reste valable pour toutes les autres notations asymptotiques définies
dans ce chapitre.
(1) Dans la notation ensembliste, le caractère « : » est synonyme de « tel que ».
3.1
Notation asymptotique
41
c2 g(n)
cg(n)
f (n)
f (n)
f (n)
cg(n)
c1 g(n)
n0
n
f (n) = Q(g(n))
(a)
n0
n
f (n) = O(g(n))
(b)
n0
n
f (n) = V(g(n))
(c)
c Dunod – La photocopie non autorisée est un délit
Figure 3.1 Exemples de notations Q, O et . Dans chaque partie, la valeur de n0 est la valeur minimale possible ; n’importe quelle valeur supérieure ferait aussi l’affaire. (a) La notation Q borne
une fonction entre des facteurs constants. On écrit f(n) = Q(g(n)) s’il existe des constantes positives n0 , c1 et c2 telles que, à droite de n0 , la valeur de f(n) soit toujours comprise entre c1 g(n)
et c2 g(n) inclus. (b) La notation O donne une borne supérieure pour une fonction à un facteur
constant près. On écrit f(n) = O(g(n)) s’il existe des constantes positives n0 et c telles que, à droite
de n0 , la valeur de f(n) soit toujours inférieure ou égale à cg(n). (c) La notation V donne une
borne inférieure pour une fonction à un facteur constant près. On écrit f(n) = V(g(n)) s’il existe
des constantes positives n0 et c telles que, à droite de n0 , la valeur de f(n) soit toujours supérieure
ou égale à cg(n).
Au chapitre 2, nous avons défini de la manière informelle suivante la notation Q :
on élimine les termes d’ordre inférieur et on ignore le coefficient du terme d’ordre
supérieur. Justifions brièvement cette définition intuitive en utilisant la définition formelle pour montrer que 12 n2 − 3n = Q(n2 ). Pour ce faire, on doit déterminer des
constantes positives c1 , c2 et n0 telles que
1
c1 n2 n2 − 3n c2 n2
2
pour tout n n0 . En divisant par n2 , on obtient
1 3
c1 − c2 .
2 n
On peut s’arranger pour que le membre droit de l’inégalité soit valide pour toute
valeur de n 1, et ce en choisissant c2 1/2. De même, on peut faire en sorte que
le membre gauche de l’inégalité soit valide pour toute valeur de n 7, en choisissant
c1 1/14. Donc, en prenant c1 = 1/14, c2 = 1/2 et n0 = 7, on peut vérifier que
1 2
2
2 n − 3n = Q(n ). D’autres choix sont bien entendu possibles pour les constantes ;
l’important est qu’il existe au moins une possibilité. Notez que ces constantes dépendent de la fonction 12 n2 − 3n ; une autre fonction appartenant à Q(n2 ) exigerait
normalement des constantes différentes.
On peut également utiliser la définition formelle pour vérifier que 6n3 fi Q(n2 ).
Supposons qu’il existe c2 et n0 tels que 6n3 c2 n2 pour tout n n0 . On a alors
n c2 /6, ce qui ne peut pas être vrai pour n arbitrairement grand, puisque c2 est
constant.
3 • Croissance des fonctions
42
Intuitivement, on peut ignorer les termes d’ordre inférieur d’une fonction asymptotiquement positive pour le calcul de bornes asymptotiquement approchées, car ils
ne sont pas significatifs pour n grand. Une petite partie du terme d’ordre supérieur
suffit à rendre négligeables les termes d’ordre inférieur. Donc, si l’on donne à c1 une
valeur légèrement plus petite que le coefficient du terme d’ordre supérieur et à c2 une
valeur légèrement plus grande, on arrive à satisfaire les inégalités sous-jacentes à la
notation Q. On peut donc ignorer le coefficient du terme d’ordre supérieur, puisque
cela ne modifie c1 et c2 que d’un facteur constant égal à ce coefficient.
Comme exemple, considérons une fonction quadratique quelconque f (n) = an2 +
bn + c, où a, b et c sont des constantes, et a > 0. Si l’on néglige les termes d’ordre
à la
inférieur et la constante, on obtient f (n) = Q(n2 ). Formellement, pour arriver
même conclusion, on prend c1 = a/4, c2 = 7a/4 et n0 = 2· max((|b| /a), (|c| /a)).
Le lecteur pourra vérifier que 0 c1 n2 an2 + bn + c c2 n2 pour tout n n0 . Plus
généralement, pour tout polynôme p(n) = di=0 ai ni , où les ai sont des constantes et
ad > 0, on a p(n) = Q(nd ) (voir problème 3.1).
Comme toute constante est un polynôme de degré 0, on peut exprimer toute fonction constante par Q(n0 ) ou Q(1). La dernière notation est légèrement abusive, car
on ne sait pas quelle est la variable qui tend vers l’infini (2) . On utilisera souvent la
notation Q(1) pour signifier indifféremment une constante ou un fonction constante
d’une certaine variable.
b) Notation O
La notation Q borne asymptotiquement une fonction à la fois par excès et par défaut.
Quand on ne dispose que d’une borne supérieure asymptotique, on utilise la notation
O. Pour une fonction g(n) donnée, on note O(g(n)) (prononcer « grand O de g de n »
ou « O de g de n ») l’ensemble de fonctions suivant :
O(g(n)) = {f (n) : il existe des constantes positives c et n0 telles que
0 f (n) cg(n) pour tout n n0 } .
La notation O sert à majorer une fonction, à un facteur constant près. La figure 3.1(b) montre la signification intuitive de la notation O. Pour toutes les valeurs
de n situées à droite de n0 , la valeur de la fonction f (n) est inférieure ou égale à
cg(n).
Pour indiquer qu’une fonction f (n) est membre de l’ensemble O(g(n)), on écrit
f (n) = O(g(n)). On remarquera que f (n) = Q(g(n)) implique f (n) = O(g(n)), puisque
la notation Q est une notion plus forte que la notation O. En termes de théorie des
ensembles, on a Q(g(n)) ⊆ O(g(n)). Donc, notre preuve que toute fonction quadratique an2 + bn + c, avec a > 0, est dans Q(n2 ) prouve également que toute fonction
(2) Le véritable problème vient de ce que la notation usuelle pour les fonctions ne distingue pas les fonctions
des valeurs. En l-calcul, les paramètres d’une fonction sont clairement spécifiés : la fonction n2 pourrait
s’écrire ln.n2 , voire lr.r2 . Cela dit, adopter une notation plus rigoureuse compliquerait les manipulations
algébriques et on tolère donc cet abus.
3.1
Notation asymptotique
43
quadratique de ce genre est dans O(n2 ). Ce qui peut paraître plus surprenant, c’est
que toute fonction linéaire an + b est aussi dans O(n2 ), ce que l’on peut aisément
vérifier en prenant c = a + |b| et n0 = 1.
Le lecteur qui a déjà rencontré la notation O pourrait s’étonner que nous écrivions,
par exemple, n = O(n2 ). Dans la littérature, la notation O sert parfois de manière informelle à décrire des bornes asymptotiquement approchées, concept que nous avons,
pour notre part, défini à l’aide de la notation Q. Dans ce livre, lorsque nous écrivons
f (n) = O(g(n)), nous disons simplement qu’un certain multiple constant de g(n) est
une borne supérieure de f (n), sans indiquer quoi que ce soit sur le degré d’approximation de la borne supérieure. La distinction entre bornes supérieures asymptotiques
et bornes asymptotiquement approchées est à présent devenue classique dans la littérature sur les algorithmes.
La notation O permet souvent de décrire le temps d’exécution d’un algorithme
rien qu’en étudiant la structure globale de l’algorithme. Par exemple, la structure
de boucles imbriquées de l’algorithme du tri par insertion (chapitre 2) donne immédiatement une borne supérieure en O(n2 ) pour le temps d’exécution du cas le plus
défavorable : le coût de chaque itération de la boucle intérieure est borné supérieurement par O(1) (constante), les indices i et j valent tous deux au plus n, et la boucle
intérieure est exécutée au plus une fois pour chacune des n2 paires de valeurs de i
et j.
c Dunod – La photocopie non autorisée est un délit
Puisque la notation O décrit une borne supérieure, quand on l’utilise pour borner
le temps d’exécution du cas le plus défavorable d’un algorithme, on borne donc aussi
le temps d’exécution de cet algorithme pour des entrées quelconques. Ainsi, la borne
O(n2 ) concernant le cas le plus défavorable du tri par insertion s’applique à toutes les
entrées possibles. En revanche, la borne Q(n2 ) concernant le temps d’exécution du tri
par insertion dans le cas le plus défavorable n’implique pas que, pour chaque entrée,
le tri par insertion ait un temps d’exécution borné par Q(n2 ). Par exemple, nous avons
vu au chapitre 2 que, si l’entrée était déjà triée, le tri par insertion s’exécutait avec un
temps en Q(n).
En toute rigueur, c’est un abus de langage que de dire que le temps d’exécution
du tri par insertion est O(n2 ) ; en effet, pour n donné, le véritable temps d’exécution
dépend de la nature de l’entrée de taille n. Quand nous disons « le temps d’exécution
est O(n2 ) », nous signifions qu’il existe une fonction f (n) qui est O(n2 ) et telle que,
pour un n fixé, pour toute entrée de taille n, le temps d’exécution est borné supérieurement par f (n). Cela revient au même de dire que le temps d’exécution du cas le plus
défavorable est O(n2 ).
c) Notation V
De même que la notation O fournit une borne supérieure asymptotique pour une
fonction, la notation V fournit une borne inférieure asymptotique. Pour une fonction
g(n) donnée, on note V(g(n)) (prononcer « grand oméga de g de n » ou « oméga de g
3 • Croissance des fonctions
44
de n ») l’ensemble des fonctions suivant :
V(g(n)) = {f (n) : il existe des constantes positives c et n0 telles que
0 cg(n) f (n) pour tout n n0 } .
La signification intuitive de la notation V est illustrée par la figure 3.1(c). Pour toutes
les valeurs de n situées à droite de n0 , la valeur de f (n) est supérieure ou égale à cg(n).
A partir des définitions des notations asymptotiques vues jusqu’ici, il est facile de
prouver le théorème important suivant (voir exercice 3.1.5).
Théorème 3.1 Pour deux fonctions quelconques f (n) et g(n), on a f (n) = Q(g(n)) si
et seulement si f (n) = O(g(n)) et f (n) = V(g(n)).
Comme exemple d’application de ce théorème, notre démonstration que
+ bn + c = Q(n2 ) pour toutes constantes a, b et c, avec a > 0, implique immédiatement que an2 + bn + c = V(n2 ) et que an2 + bn + c = O(n2 ). Dans la pratique,
au lieu d’utiliser le théorème 3.1 pour obtenir des bornes supérieure et inférieure
asymptotiques à partir de bornes asymptotiquement approchées, comme nous l’avons
fait dans cet exemple, on l’utilise plutôt pour calculer des bornes asymptotiquement
approchées à partir de bornes supérieure et inférieure asymptotiques.
Puisque la notation V décrit une borne inférieure, quand on l’utilise pour borner le
temps d’exécution d’un algorithme dans le cas optimal, on borne alors également le
temps d’exécution de l’algorithme pour des entrées arbitraires. Par exemple, le temps
d’exécution du tri par insertion dans cas optimal est V(n), ce qui implique que le
temps d’exécution du tri par insertion est V(n).
Le temps d’exécution du tri par insertion est donc compris entre V(n) et O(n2 ),
puisqu’il se situe quelque part entre une fonction linéaire de n et une fonction quadratique de n. De plus, ces bornes sont asymptotiquement aussi approchées que possible : par exemple, le temps d’exécution du tri par insertion n’est pas V(n2 ), car le
tri prend un temps Q(n) lorsque l’entrée est déjà triée. Cependant, il n’est pas faux
de dire que le temps d’exécution du tri par Insertion dans le cas le plus défavorable
est V(n2 ), puisqu’il existe une entrée pour laquelle l’algorithme dure un temps V(n2 ).
Quand on dit que le temps d’exécution (tout court) d’un algorithme est V(g(n)), on
signifie que, pour n suffisamment grand, quelle que soit l’entrée de taille n choisie
pour une valeur quelconque de n, le temps d’exécution pour cette entrée est au moins
un multiple constant de g(n).
an2
d) Notation asymptotique dans les équations et les inégalités
Nous avons déjà vu comment les notations asymptotiques s’utilisent dans les formules mathématiques. Par exemple, quand nous avons introduit la notation O, nous
avons écrit « n = O(n2 ) ». On aurait pu écrire également 2n2 + 3n + 1 = 2n2 + Q(n).
Quelle interprétation donner à ces formules ?
Lorsque la notation asymptotique se trouve isolée dans le membre droit d’une
équation (ou d’une inégalité), comme dans n = O(n2 ), le signe égal est, comme nous
3.1
Notation asymptotique
45
l’avons vu, synonyme d’appartenance : n ∈ O(n2 ). En général, toutefois, quand une
notation asymptotique apparaît dans une formule, on l’interprète comme représentant
une certaine fonction anonyme. Par exemple, la formule 2n2 + 3n + 1 = 2n2 + Q(n)
signifie que 2n2 +3n+1 = 2n2 +f (n), où f (n) est une fonction particulière de l’ensemble
Q(n). Ici f (n) = 3n + 1, qui appartient bien à Q(n).
Cet emploi de la notation asymptotique aide à éliminer les détails superflus dans
une équation. Par exemple, au chapitre 2, nous avons exprimé le temps d’exécution
du tri par fusion dans le cas le plus défavorable sous la forme de la récurrence
T(n) = 2T(n/2) + Q(n) .
Si l’on ne s’intéresse qu’au comportement asymptotique de T(n), il n’y a aucune
raison de spécifier tous les termes d’ordre inférieur ; l’on considère qu’ils sont tous
compris dans la fonction anonyme représentée par le terme Q(n).
On considère que le nombre de fonctions anonymes dans une expression est égal
au nombre d’apparitions de la notation asymptotique. Par exemple, l’expression
n
O(i) ,
i=1
ne contient qu’une seule fonction anonyme (une fonction de i). Cette expression n’est
donc pas la même que O(1) + O(2) + · · · + O(n), qui n’a pas d’interprétation très nette.
Dans certains cas, une notation asymptotique apparaît dans le membre gauche
d’une équation, comme dans
c Dunod – La photocopie non autorisée est un délit
2n2 + Q(n) = Q(n2 ) .
On interprète ce type d’équation à l’aide de la règle suivante : Quelle que soit la
manière dont on choisit les fonctions anonymes à gauche du signe égal, il existe une
manière de choisir les fonctions anonymes à droite du signe égal qui rende l’équation
valide. La signification de notre exemple est donc que, pour une fonction quelconque
f (n) ∈ Q(n), il existe une certaine fonction g(n) ∈ Q(n2 ) telle que 2n2 + f (n) = g(n)
pour tout n. Autrement dit, le membre droit d’une équation fournit un niveau de détail
plus grossier que le membre gauche.
On peut enchaîner plusieurs relations de ce type, comme dans
2n2 + 3n + 1 = 2n2 + Q(n)
= Q(n2 ) .
On peut interpréter chaque équation séparément, grâce à la règle précédente. La
première équation dit qu’il existe une certaine fonction f (n) ∈ Q(n) telle que
2n2 + 3n + 1 = 2n2 + f (n) pour tout n. La seconde équation dit que, pour toute fonction g(n) ∈ Q(n) (par exemple, la fonction f (n) précédente), il existe une certaine
fonction h(n) ∈ Q(n2 ) telle que 2n2 + g(n) = h(n) pour tout n. On notera que cette
interprétation implique que 2n2 + 3n + 1 = Q(n2 ), ce que d’ailleurs l’enchaînement
des équations suggère intuitivement.
3 • Croissance des fonctions
46
e) Notation o
La borne supérieure asymptotique fournie par la notation O peut être ou non asymptotiquement approchée. La borne 2n2 = O(n2 ) est asymptotiquement approchée, mais
la borne 2n = O(n2 ) ne l’est pas. On utilise la notation o pour noter une borne supérieure qui n’est pas asymptotiquement approchée. On définit formellement o(g(n))
(« petit o de g de n ») comme étant l’ensemble
o(g(n)) = {f (n) : pour toute constante c > 0, il existe une constante
n0 > 0 telle que 0 f (n) < cg(n) pour tout n n0 } .
Par exemple, 2n = o(n2 ) mais 2n2 fi o(n2 ).
Les définitions des notations O et o se ressemblent. La différence principale est
que dans f (n) = O(g(n)), la borne 0 f (n) cg(n) est valable pour une certaine
constante c > 0, alors que dans f (n) = o(g(n)), la borne 0 f (n) < cg(n) est valable
pour toutes les constantes c > 0. De manière intuitive, avec la notation o, la fonction
f (n) devient négligeable par rapport à g(n) quand n tend vers l’infini ; en d’autres
termes
f (n)
lim
=0.
(3.1)
n→∞ g(n)
Certains auteurs utilisent cette limite comme définition de la notation o ; la définition
donnée dans ce livre impose en outre que les fonctions anonymes soient asymptotiquement positives.
f) Notation v
Par analogie, la notation v est à la notation V ce que la notation o est à la notation
O. On utilise la notation v pour indiquer une borne inférieure qui n’est pas asymptotiquement approchée. Une façon de la définir est
f (n) ∈ v(g(n)) si et seulement si g(n) ∈ o(f (n)) .
Formellement, on définit v(g(n)) (« petit oméga de g de n ») comme l’ensemble
v(g(n)) = {f (n) : pour toute constante c > 0, il existe une constante
n0 > 0 telle que 0 cg(n) < f (n) pour tout n n0 } .
Par exemple, n2 /2 = v(n) mais n2 /2 fi v(n2 ). La relation f (n) = v(g(n)) implique
que
f (n)
=∞,
lim
n→∞ g(n)
si la limite existe. C’est-à-dire que f (n) devient arbitrairement grande par rapport à
g(n) quand n tend vers l’infini.
3.1
Notation asymptotique
47
g) Comparaison de fonctions
Nombre de propriétés relationnelles des nombres réels s’appliquent aussi aux comparaisons asymptotiques. Dans ce qui suit, on supposera que f (n) et g(n) sont asymptotiquement positives.
Transitivité :
f (n) = Q(g(n)) et g(n) = Q(h(n))
implique
f (n) = Q(h(n)) ,
f (n) = O(g(n)) et g(n) = O(h(n))
implique
f (n) = O(h(n)) ,
f (n) = V(g(n)) et g(n) = V(h(n))
implique
f (n) = V(h(n)) ,
f (n) = o(g(n)) et g(n) = o(h(n))
implique
f (n) = o(h(n)) ,
f (n) = v(g(n)) et g(n) = v(h(n))
implique
f (n) = v(h(n)) .
Réflexivité :
f (n) = Q(f (n)) ,
f (n) = O(f (n)) ,
f (n) = V(f (n)) .
Symétrie :
f (n) = Q(g(n)) si et seulement si g(n) = Q(f (n)) .
Symétrie transposée :
f (n) = O(g(n)) si et seulement si g(n) = V(f (n)) ,
f (n) = o(g(n)) si et seulement si g(n) = v(f (n)) .
c Dunod – La photocopie non autorisée est un délit
Comme ces propriétés sont vraies pour des notations asymptotiques, on peut dégager une analogie entre la comparaison asymptotique de deux fonctions f et g et la
comparaison de deux réels a et b :
f (n) = O(g(n))
f (n) = V(g(n))
f (n) = Q(g(n))
f (n) = o(g(n))
f (n) = v(g(n))
≈
≈
≈
≈
≈
ab,
ab,
a=b,
a<b,
a>b.
Nous dirons que f (n) est asymptotiquement inférieure à g(n) si f (n) = o(g(n)), et que
f (n) est asymptotiquement supérieure à g(n) si f (n) = v(g(n)). Toutefois, il existe
une propriété des nombres réels qui n’est pas applicable à la notation asymptotique :
Trichotomie : Pour deux nombres réels quelconques a et b, une et une seule des
propositions suivantes est vraie : a < b, a = b ou a > b.
Alors que deux nombres réels peuvent toujours être comparés, deux fonctions ne sont
pas toujours comparables asymptotiquement. En d’autres termes, pour deux fonctions f (n) et g(n), il se peut que l’on n’ait ni f (n) = O(g(n)), ni f (n) = V(g(n)). Par
3 • Croissance des fonctions
48
exemple, les fonctions n et n1+sin n ne peuvent pas être comparées à l’aide d’une notation asymptotique, puisque la valeur de l’exposant dans n1+sin n oscille entre 0 et 2
en prenant successivement toutes les valeurs de cet intervalle.
Exercices
3.1.1 Soient f (n) et g(n) des fonctions asymptotiquement non négatives. En s’aidant de la
définition de base de la notation Q, prouver que max(f (n), g(n)) = Q(f (n) + g(n)).
3.1.2 Montrer que, pour deux constantes réelles a et b quelconques avec b > 0, l’on a
(n + a)b = Q(nb ) .
(3.2)
3.1.3 Expliquer pourquoi l’affirmation « Le temps d’exécution de l’algorithme A est au
moins O(n2 ) » n’a pas de sens.
3.1.4 Est-ce que 2n+1 = O(2n ) ? Est-ce que 22n = O(2n ) ?
3.1.5 Démontrer le théorème 3.1.
3.1.6 Démontrer que le temps d’exécution d’un algorithme est Q(g(n)) si et seulement si son
temps d’exécution dans le cas le plus défavorable est O(g(n)) et son temps d’exécution dans
le meilleur des cas est V(g(n)).
3.1.7 Démontrer que o(g(n)) ∩ v(g(n)) est l’ensemble vide.
3.1.8 On peut étendre notre notation au cas de deux paramètres n et m qui tendent vers
l’infini indépendamment à des vitesses différentes. Pour une fonction donnée g(n, m), on
note O(g(n, m)) l’ensemble des fonctions
O(g(n, m)) = {f (n, m) : il existe des constantes positives c, n0 et m0
telles que 0 f (n, m) cg(n, m)
pour tout n n0 et tout m m0 } .
Donner des définitions correspondantes pour V(g(n, m)) et Q(g(n, m)).
3.2 NOTATIONS STANDARD ET FONCTIONS CLASSIQUES
Cette section va revoir certaines fonctions et notations mathématiques standard et
examiner les relations entre elles. Elle va également illustrer l’utilisation des notations asymptotiques.
3.2
Notations standard et fonctions classiques
49
a) Monotonie
Une fonction f (n) est monotone croissante si m n implique f (m) f (n). De même,
elle est monotone décroissante si m n implique f (m) f (n). Une fonction f (n) est
strictement croissante si m < n implique f (m) < f (n) et strictement décroissante si
m < n implique f (m) > f (n).
b) Parties entières
Pour tout réel x, on représente le plus grand entier inférieur ou égal à x par x (lire
« partie entière de x ») et le plus petit entier supérieur ou égal à x par x (lire « partie
entière supérieure de x »). Pour tout x réel, on a
x−1 < x
x x < x + 1 .
(3.3)
Pour un entier n quelconque, on a
n/2 + n/2 = n ,
et pour un réel n 0 et des entiers a, b > 0, l’on a
n/a /b = n/ab ,
n/a /b = n/ab ,
a/b
(a + (b − 1))/b ,
a/b
((a − (b − 1))/b .
Les fonctions x et x sont toutes les deux monotones croissantes.
(3.4)
(3.5)
(3.6)
(3.7)
c) Congruences
c Dunod – La photocopie non autorisée est un délit
Pour tout entier a et pour tout entier positif n, la valeur a mod n est le reste du quotient
a/n :
a mod n = a − a/n n .
(3.8)
Partant de la notion connue de reste de la division d’un entier par un autre, il
est commode de fournir une notation spéciale pour indiquer l’égalité des restes. Si
(a mod n) = (b mod n), on écrit a ≡ b (mod n) et l’on dit que a est congru à b
modulo n. En d’autres termes, a ≡ b (mod n) si a et b ont le même reste dans la
division par n. Il revient au même de dire que a ≡ b (mod n) si et seulement si n est
un diviseur de b − a. On écrira a ≡ b (mod n) si a n’est pas congru à b modulo n.
d) Polynômes
Étant donné un entier non négatif d, un polynôme en n de degré d est une fonction
p(n) de la forme
d
ai ni ,
p(n) =
i=0
3 • Croissance des fonctions
50
les constantes a0 , a1 , . . . , ad étant les coefficients du polynôme et ad fi 0. Un polynôme est asymptotiquement positif si et seulement si ad > 0. Pour un polynôme
asymptotiquement positif p(n) de degré d, on a p(n) = Q(nd ). Pour toute constante
réelle a 0, la fonction na est monotone croissante ; pour toute constante réelle
a 0, la fonction na est monotone décroissante. On dit qu’une fonction f (n) a une
borne polynomiale si f (n) = O(nk ) pour une certaine constante k.
e) Exponentielles
Pour tous réels a fi 0, m et n, on a les identités suivantes :
a0 = 1 ,
a1 = a ,
a−1 = 1/a ,
(am )n = amn ,
(am )n = (an )m ,
am an = am+n .
Pour tout n et tout a 1, la fonction an est monotone croissante en n. Quand cela
s’avérera pratique, nous supposerons que 00 = 1.
Il existe une relation entre les vitesses de croissance des polynômes et des exponentielles. Pour toutes constantes réelles a et b telles que a > 1, on a :
nb
=0,
n→∞ an
lim
(3.9)
d’où l’on peut conclure que
nb = o(an ) .
Ainsi, toute fonction exponentielle dont la base est strictement plus grande que 1 croît
plus vite que toute fonction polynomiale.
En notant e le réel 2, 71828 . . ., base de la fonction logarithme népérien (naturel),
on a pour tout réel x :
∞
xi
x2 x3
+
+ ··· =
,
e =1+x+
2! 3!
i!
x
(3.10)
i=0
où « ! » représente la fonction factorielle définie plus loin dans cette section. Pour
tout réel x, on a l’inégalité
(3.11)
ex 1 + x ,
(l’égalité n’a lieu que pour x = 0). Lorsque |x| 1, on a l’approximation
1 + x ex 1 + x + x2 .
Quand x → 0, l’approximation de ex par 1 + x est assez bonne :
ex = 1 + x + Q(x2 ) .
(3.12)
3.2
Notations standard et fonctions classiques
51
(Dans cette équation, la notation asymptotique sert à décrire le comportement aux
limites quand x → 0 et non quand x → ∞.) On a pour tout x :
x n
= ex .
(3.13)
lim 1 +
n→∞
n
f) Logarithmes
On utilisera les notations suivantes :
lg n = log2 n (logarithme de base 2) ,
ln n = loge n
lgk n = (lg n)k
(logarithme népérien) ,
(exponentiation) ,
lg lg n = lg(lg n) (composition) .
Nous adopterons la convention notationnelle importante selon laquelle les fonctions
logarithme s’appliquent uniquement au terme qui suit dans la formule, de sorte que
lg n + k signifiera (lg n) + k et non lg(n + k). Si nous prenons b > 1 constant, alors
pour n > 0 la fonction logb n est strictement croissante. Pour tous réels a > 0, b > 0,
c > 0 et n, on a :
a = blogb a ,
logc (ab) = logc a + logc b ,
logb an = n logb a ,
logb a =
logc a
,
logc b
(3.14)
logb (1/a) = − logb a ,
logb a =
1
,
loga b
c Dunod – La photocopie non autorisée est un délit
alogb c = clogb a ,
(3.15)
avec, dans chacune de ces équations, une base de logarithme qui n’est pas 1.
D’après l’équation (3.14), changer la base d’un logarithme ne modifie la valeur
du logarithme que d’un facteur constant ; nous utiliserons donc souvent la notation
« lg n » lorsque nous ne nous soucierons pas des facteurs constants, par exemple
dans la notation O. Pour les informaticiens, 2 est la base la plus naturelle pour les
logarithmes ; en effet, nombreux sont les algorithmes et structures de données qui
impliquent la séparation d’un problème en deux parties.
Il existe un développement en série simple pour ln(1 + x) quand |x| < 1 :
ln(1 + x) = x −
x2 x3 x4 x5
+
−
+
− ···
2
3
4
5
3 • Croissance des fonctions
52
On a également les inégalités suivantes pour x > −1 :
x
ln(1 + x) x ,
1+x
(l’égalité n’a lieu que pour x = 0).
(3.16)
On dit qu’une fonction f (n) a une borne polylogarithmique si f (n) = O(lgk n) pour
une certaine constante k. On peut mettre en relation la croissance des polynômes et
des polylogarithmes en substituant lg n à n et 2a à a dans l’équation (3.9), ce qui
donne
lgb n
lgb n
=0.
lim a lg n = lim
n→∞ (2 )
n→∞ na
De cette limite, on peut conclure que
lgb n = o(na )
pour toute constante a > 0. Ainsi, toute fonction polynomiale positive croît plus vite
que toute fonction polylogarithmique.
g) Factorielles
La notation n! (lire « factorielle n ») est définie comme suit pour les entiers n 0 :
1
si n = 0 ,
n! =
n·(n − 1)! si n > 0 .
Donc, n! = 1·2·3 · · · n.
Une borne supérieure faible de la fonction factorielle est n! nn , puisque chacun
des n termes du produit factoriel vaut au plus n. La formule de Stirling,
n n 1 √
,
(3.17)
1+Q
n! = 2pn
e
n
e étant la base du logarithme naturel, donne une borne supérieure plus serrée ainsi
qu’une borne inférieure. On peut montrer (voir exercice 3.2.3)
n! = o(nn ) ,
n! = v(2n ) ,
lg(n!) = Q(n lg n) ,
(3.18)
la formule de Stirling servant ici à prouver l’équation (3.18). L’équation suivante est
également vraie pour tout n 1 :
n n
√
ean
(3.19)
n! = 2pn
e
avec
1
1
< an <
.
(3.20)
12n + 1
12n
3.2
Notations standard et fonctions classiques
53
h) Itération fonctionnelle
La notation f (i) (n) représente la fonction f (n) appliquée, de manière itérative, i fois à
une valeur initiale n. De manière plus formelle, soit f (n) une fonction définie sur les
réels. Pour les entiers non négatifs i, on définit récursivement
n
si i = 0 ,
(i)
f (n) =
f (f (i−1) (n)) si i > 0 .
Par exemple, si f (n) = 2n alors f (i) (n) = 2i n.
i) Fonction logarithme itéré
La notation lg∗ n (lire « log étoile de n ») représente le logarithme itéré, ainsi défini : soit lg(i) n la fonction définie itérativement comme précédemment exposé, où ici
f (n) = lg n. Comme le logarithme n’est défini que pour les nombres positifs, lg(i) n
n’est définie que si lg(i−1) n > 0. Ne confondez pas lg(i) n (fonction logarithme appliquée i fois successivement, l’argument de départ étant n) et lgi n (logarithme de n
élevé à la puissance ith). La fonction logarithme itéré est définie ainsi :
lg∗ n = min {i 0 : lg(i) n 1} .
Le logarithme itéré est une fonction à croissance très lente :
lg∗ 2
lg∗ 4
lg∗ 16
lg∗ 65536
=
=
=
=
1,
2,
3,
4,
lg∗ (265536 ) = 5 .
c Dunod – La photocopie non autorisée est un délit
Le nombre d’atomes de l’univers observable étant estimé à environ 1080 , valeur très
inférieure à 265536 , il est rare de tomber sur une entrée de taille n tel que lg∗ n > 5.
j) Nombres de Fibonacci
Les nombres de Fibonacci sont définis par la récurrence suivante :
F0 = 0 ,
F1 = 1 ,
Fi = Fi−1 + Fi−2
(3.21)
pour i 2 .
Chaque nombre de Fibonacci est donc la somme des deux précédents, ce qui donne
la suite
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, . . . .
3 • Croissance des fonctions
54
Les nombres Fibonacci sont étroitement liés au nombre d’or f et à son conjugué f,
donnés par les formules suivantes :
√
1+ 5
f =
(3.22)
2
= 1.61803 . . . ,
√
1− 5
f =
2
= −.61803 . . . .
Plus précisément, on a
Fi =
i
fi − f
√
,
5
(3.23)
< 1, on a
ce qu’on
peut √prouver par récurrence (exercice 3.2.6). Comme |f|
√
i |/ 5 < 1/ 5 < 1/2 ; de sorte que le i-ème nombre de Fibonacci Fi est égal
|f
√
à fi / 5, arrondi à l’entier le plus proche. Les nombres de Fibonacci croissent donc
de façon exponentielle.
Exercices
3.2.1 Montrer que, si f (n) et g(n) sont des fonctions monotones croissantes, alors les fonctions f (n) + g(n) et f (g(n)) le sont également ; montrer que, si f (n) et g(n) sont en outre non
négatives, f (n)·g(n) est monotone croissante.
3.2.2 Démontrer l’équation (3.15).
3.2.3 Prouver l’équation (3.18). Montrer aussi que n! = v(2n ) et n! = o(nn ).
3.2.4 La fonction lg n! a-t-elle une borne polynomiale ? Et la fonction lg lg n! ?
3.2.5 Laquelle de ces deux fonctions est la plus grande asymptotiquement : lg(lg∗ n) ou
lg∗ (lg n) ?
3.2.6 Démontrer par récurrence que le i-ème nombre de Fibonacci satisfait à l’égalité
Fi =
i
fi − f
√
,
5
son conjugué.
où f est le nombre d’or et f
3.2.7 Démontrer que, pour i 0, le (i + 2)-ème nombre de Fibonacci satisfait à l’inégalité
Fi+2 fi .
Problèmes
55
PROBLÈMES
3.1. Comportement asymptotique des polynômes
Soit
d
ai ni ,
p(n) =
i=0
avec ad > 0, un polynôme en n de degré d, et soit k une constante. Utiliser les
définitions des notations asymptotiques pour démontrer les propriétés suivantes.
a. Si k d, alors p(n) = O(nk ).
b. Si k d, alors p(n) = V(nk ).
c. Si k = d, alors p(n) = Q(nk ).
d. Si k > d, alors p(n) = o(nk ).
e. Si k < d, alors p(n) = v(nk ).
c Dunod – La photocopie non autorisée est un délit
3.2. Croissances asymptotiques relatives
Indiquer, pour chaque paire d’expressions (A, B) du tableau ci-après, si A est O, o, V,
v ou Q de B. On suppose que k 1, ´ > 0 et c > 1 sont des constantes. Répondre
en écrivant « oui » ou « non » dans chaque case du tableau.
A
B
a.
lgk n
n´
b.
cn
c.
nk
√
n
nsin n
d.
2n
2n/2
e.
nlg m
mlg n
f.
lg(n!) lg(nn )
O
o
V
v
Q
3.3. Classement par vitesses de croissance asymptotiques
a. Ranger les fonctions suivantes par ordre de croissance ; c’est-à-dire, trouver un
ordre g1 , g2 , . . . , g30 pour ces fonctions tel que g1 = V(g2 ), g2 = V(g3 ), . . . ,
g29 = V(g30 ). Partitionner la liste en classes d’équivalence telles que f (n) et g(n)
sont dans la même classe si et seulement si f (n) = Q(g(n)).
3 • Croissance des fonctions
56
lg(lg∗ n)
∗
2lg
n
√
( 2)lg n
n2
n!
(lg n)!
2n
n1/ lg n
( 32 )n
n3
lg2 n
lg(n!)
2
ln ln n
lg∗ n
n·2n
nlg lg n
ln n
2lg n
(lg n)lg n
en
4lg n
(n + 1)!
lg∗ (lg n)
√
2 2 lg n
n
2n
n lg n
√
1
lg n
22
n+1
b. Trouver une fonction non négative f (n) telle que, pour toutes les fonctions gi (n) de
la partie (a), f (n) ne soit ni O(gi (n)) ni V(gi (n)).
3.4. Propriétés de la notation asymptotique
Soient f (n) et g(n) des fonctions asymptotiquement positives. Prouver ou démentir
chacune des affirmations suivantes.
a. f (n) = O(g(n)) implique g(n) = O(f (n)).
b. f (n) + g(n) = Q(min(f (n), g(n))).
c. f (n) = O(g(n)) implique lg(f (n)) = O(lg(g(n))), où lg(g(n)) 1 et f (n) 1 pour
tout n suffisamment grand.
d. f (n) = O(g(n)) implique 2f (n) = O (2g(n) ).
e. f (n) = O ((f (n))2 ).
f. f (n) = O(g(n)) implique g(n) = V(f (n)).
g. f (n) = Q(f (n/2)).
h. f (n) + o(f (n)) = Q(f (n)).
3.5. Variations sur O et V
Certains auteurs définissent V d’une façon légèrement différente de la nôtre ; notons
∞
∞
V (lire « oméga infini ») cette autre définition. On dit que f (n) = V(g(n)) s’il existe
une constante positive c telle que f (n) cg(n) 0 pour un nombre infiniment grand
d’entiers n.
a. Montrer que, pour deux fonctions quelconques f (n) et g(n) qui sont asymptotique∞
ment non négatives, on a soit f (n) = O(g(n)), soit f (n) = V(g(n)), soit les deux,
∞
alors que cela est faux si l’on utilise V au lieu de V.
b. Expliquer quels sont les avantages et inconvénients potentiels qu’entraîne l’utilisa∞
tion de V à la place de V pour caractériser le temps d’exécution des programmes.
Certains auteurs définissent également O d’une façon légèrement différente de la
nôtre ; notons O cette autre définition. On dit que f (n) = O (g(n)) si et seulement si
|f (n)| = O(g(n)).
Notes
57
c. Qu’advient-il, pour chaque sens du « si et seulement si » du théorème 3.1, si l’on
substitue O à O tout en continuant d’utiliser V ?
(lire « O tilde ») pour signifier O sans les facteurs
Certains auteurs définissent O
logarithmiques :
O(g(n))
= {f (n) : il existe des constantes positives c, k et n0 telles que
0 f (n) cg(n) lgk (n) pour tout n n0 } .
et Q
d’une manière similaire. Prouver le théorème qui est l’homologue
d. Définir V
du théorème 3.1.
3.6. Fonctions itérées
L’opérateur d’itération ∗ utilisé dans la fonction lg∗ peut s’appliquer à toute fonction
monotone croissante à variable réelle. Pour une constante donnée c ∈ R, on définit
la fonction itérée fc∗ par
fc∗ (n) = min {i 0 : f (i) (n) c} ,
qui n’est pas obligée d’être bien définie dans tous les cas. En d’autres termes, la
quantité fc∗ (n) est le nombre de fois qu’il faut appliquer, de manière itérée, la fonction
f pour faire tomber son argument à une valeur inférieure ou égale à c. Pour chacune
des fonctions f (n) et des constantes c suivantes, donner une borne aussi approchée
que possible pour fc∗ (n).
a.
f (n)
n−1
c
0
b.
lg n
1
c.
n/2
1
d.
2
f.
n/2
√
n
√
n
g.
n1/3
2
c Dunod – La photocopie non autorisée est un délit
e.
h. n/ lg n
fc∗ (n)
2
1
2
NOTES
Knuth [182] fait remonter les origines de la notation O à un texte sur la théorie des nombres
écrit par P. Bachmann en 1892. La notation o fut inventée par E. Landau en 1909 pour
son étude sur la distribution des nombres premiers. L’emploi des notations V et Q a été
recommandé par Knuth [186] qui dénonce la pratique, très répandue mais peu rigoureuse,
qui consiste à utiliser la notation O aussi bien pour les bornes supérieures que les bornes
inférieures. Maintes personnes continuent à utiliser la notation O là où la notation Q serait
58
3 • Croissance des fonctions
plus appropriée. On trouvera une étude plus précise de l’histoire et du développement des
notations asymptotiques dans Knuth [182, 186], ainsi que dans Brassard et Bratley [46].
Tous les auteurs ne définissent pas les notations asymptotiques de la même façon, bien que
les différentes définitions se rejoignent dans la plupart des situations concrètes. Certaines de
ces autres définitions englobent les fonctions qui ne sont pas asymptotiquement non négatives, à condition que leurs valeurs absolues soient bornées comme il faut.
L’équation (3.19) est due à Robbins [260]. On trouvera d’autres propriétés des fonctions
mathématiques élémentaires dans tout bon manuel de mathématiques générales, dont Abramowitz et Stegun [1] ou Zwillinger [320], ou encore dans un manuel de calcul numérique, tel
Apostol [18] ou Thomas et Finney [296]. Knuth [182] et Graham, Knuth et Patashnik [132]
renferment quantité de choses sur les mathématiques discrètes appliquées à l’informatique.
Chapitre 4
Récurrences
Comme nous l’avons vu à la section 2.3.2, quand un algorithme contient un appel récursif à lui-même, son temps d’exécution peut souvent être décrit par une récurrence.
Une récurrence est une équation ou inégalité qui décrit une fonction à partir de sa
valeur sur des entrées plus petites. Par exemple, nous avons vu à la section 2.3.2 que
le temps T(n) d’exécution du cas le plus défavorable de la procédure T RI -F USION
pouvait être décrit par la récurrence :
Q(1)
si n = 1 ,
T(n) =
(4.1)
2T(n/2) + Q(n) si n > 1 ,
c Dunod – La photocopie non autorisée est un délit
dont nous avions affirmé que la solution était T(n) = Q(n lg n).
Ce chapitre va présenter trois méthodes pour résoudre les récurrences, c’est-à-dire
pour obtenir des bornes asymptotiques « Q » ou « O » pour la solution. Dans la
méthode de substitution, on devine une borne puis on utilise une récurrence mathématique pour démontrer la validité de la conjecture. La méthode de l’arbre récursif
convertit la récurrence en un arbre dont les nœuds représentent les coûts induits à
différents niveaux de la récursivité ; nous emploierons des techniques de bornage de
sommation pour résoudre la récurrence. La méthode générale fournit des bornes pour
les récurrences de la forme
T(n) = aT(n/b) + f (n),
où a 1, b > 1 et f (n) est une fonction donnée ; cette méthode oblige à traiter trois
cas distincts, mais facilite la détermination des bornes asymptotiques pour nombre
de récurrences simples.
4 • Récurrences
60
a) Considérations techniques
En pratique, on néglige certains détails techniques pour énoncer et résoudre les récurrences. Par exemple, on passe souvent sur le fait que les arguments des fonctions sont
des entiers. Normalement, le temps d’exécution T(n) d’un algorithme n’est défini que
pour n entier puisque, dans la plupart des algorithmes, la taille de l’entrée a toujours
une valeur entière. Par exemple, la récurrence décrivant le temps d’exécution de T RI F USION dans le cas le plus défavorable devrait en fait s’écrire :
Q(1)
si n = 1 ,
T(n) =
(4.2)
T(n/2) + T( n/2 ) + Q(n) si n > 1 .
Autre classe de détails le plus souvent ignorée : les conditions aux limites. Puisque
le temps d’exécution d’un algorithme sur une entrée de taille constante est une
constante, les récurrences sous-jacentes au calcul du temps d’exécution des algorithmes ont généralement T(n) = Q(1) pour n suffisamment petit. Du coup, pour
simplifier, on omet généralement de définir les conditions aux limites des récurrences
et l’on suppose que T(n) est constant pour n petit. Par exemple, la récurrence (4.1)
est généralement définie de la façon suivante :
T(n) = 2T(n/2) + Q(n) ,
(4.3)
sans que l’on donne explicitement des valeurs pour n petit. La raison en est la suivante : bien que changer la valeur de T(1) ait pour effet de changer la solution de la
récurrence, en général cette solution ne change pas de plus d’un facteur constant, ce
qui ne modifie pas l’ordre de grandeur.
Quand on définit et résout des récurrences, on omet souvent les parties entières et
les conditions aux limites. On va de l’avant sans se préoccuper de ces détails puis,
ultérieurement, on voit s’ils sont importants ou non. En général ils ne sont pas importants, mais il est vital de savoir quand ils le sont. L’expérience aide, de même que
certains théorèmes énonçant que ces détails n’affectent pas les bornes asymptotiques
de moult récurrences rencontrées dans l’analyse des algorithmes (voir théorème 4.1).
Dans ce chapitre, toutefois, on abordera certains de ces détails afin d’illustrer les
subtilités des méthodes de résolution de récurrence.
4.1 MÉTHODE DE SUBSTITUTION
La méthode de substitution recouvre deux phases :
1) Conjecturer la forme de la solution.
2) Employer une récurrence mathématique pour trouver les constantes et prouver que
la solution est correcte.
Le nom vient du fait que l’on substitue la réponse supposée à la fonction quand
on applique l’hypothèse de récurrence aux valeurs plus petites Cette méthode est
puissante, mais ne peut manifestement s’utiliser que lorsque la forme de la réponse
4.1
Méthode de substitution
61
est facile à deviner. La méthode de substitution peut servir à borner une récurrence par
excès ou par défaut. Par exemple, calculons une borne supérieure pour la récurrence
T(n) = 2T( n/2 ) + n ,
(4.4)
qui ressemble aux récurrences (4.2) et (4.3). On conjecture que la solution est
T(n) = O(n lg n). La méthode consiste à démontrer que T(n) cn lg n pour un choix
approprié de la constante c > 0. On commence par supposer que cette borne est
valable pour n/2 , autrement dit que T( n/2 ) c n/2 lg( n/2 ). La substitution
donne
T(n) =
=
2(c n/2 lg( n/2 )) + n
cn lg(n/2) + n
cn lg n − cn lg 2 + n
cn lg n − cn + n
cn lg n ,
la dernière étape étant vraie si c 1.
c Dunod – La photocopie non autorisée est un délit
L’induction mathématique exige que nous montrions que notre solution est valable
pour les conditions aux limites. En général, on le fait en montrant que les conditions
aux limites peuvent servir de cas initiaux pour la démonstration par récurrence. Pour
la récurrence (4.4), on doit montrer qu’il est possible de choisir une constante c suffisamment grande pour que la borne T(n) cn lg n soit vraie aussi pour les conditions
aux limites. Cette contrainte peut parfois induire des problèmes. Supposons, pour les
besoins de la discussion, que T(1) = 1 soit l’unique condition aux limites pour la récurrence. Alors, pour n = 1, la borne T(n) cn lg n donne T(1) c1 lg 1 = 0, ce
qui contredit T(1) = 1. Par conséquent, le cas initial de notre démonstration inductive
n’est pas vérifié.
Il est facile de passer outre cette difficulté qu’il y a à prouver une hypothèse
de récurrence pour une certaine condition aux limites. Par exemple, dans la récurrence (4.4), on profite de ce que la notation asymptotique nous oblige uniquement à
démontrer que T(n) cn lg n pour n n0 , où n0 est une constante de notre choix.
L’idée est de supprimer, dans la démonstration par récurrence, l’obligation de considérer la délicate condition aux limites T(1) = 1. Observez que, pour n > 3, la récurrence ne dépend pas directement de T(1). On peut donc remplacer T(1) par T(2) et
T(3) comme cas initiaux de la démonstration par récurrence, en faisant n0 = 2. Notez
que nous faisons une distinction entre le cas initial de la récurrence (n = 1) et les
cas initiaux de la démonstration (n = 2 et n = 3). Nous déduisons de la récurrence
que T(2) = 4 et T(3) = 5. Nous pouvons maintenant compléter la démonstration par
récurrence que T(n) cn lg n pour une certaine constante c 1 en choisissant c
suffisamment grand pour que T(2) c2 lg 2 et T(3) c3 lg 3. En fait, on constate
que n’importe quel choix de c 2 valide les cas initiaux de n = 2 et n = 3. Pour la
plupart des récurrences que nous étudierons, il sera immédiat d’étendre les conditions
aux limites de façon que l’hypothèse de récurrence soit vraie pour n petit.
4 • Récurrences
62
b) De l’art de bien conjecturer
Malheureusement, il n’existe de pas de règle générale pour conjecturer les solutions
des récurrences. Deviner une solution ressort de l’expérience et, parfois, de l’intuition
pure. Cela dit, il existe certaines heuristiques qui vous aideront à devenir un bon
devin. Vous pouvez aussi faire appel aux arbres de récursivité, que nous verrons à la
section 4.2, pour générer de bonnes conjectures.
Si une récurrence ressemble à une autre rencontrée précédemment, il est raisonnable de conjecturer une solution similaire. Par exemple, la récurrence
T(n) = 2T( n/2 + 17) + n ,
semble difficile à cause du « 17 » ajouté à l’argument de T dans le membre droit.
Toutefois, on sent bien que ce terme supplémentaire n’affecte pas sensiblement la
solution de la récurrence. Quand n devient grand, la différence entre T( n/2 ) et
T( n/2 + 17) n’est plus aussi importante : ces deux expressions divisent n par deux.
On suppose donc que T(n) = O(n lg n), puis on vérifiera la validité de la conjecture
en utilisant la méthode de substitution (voir exercice 4.1.5).
Un autre moyen de bien conjecturer consiste à trouver des bornes supérieure et inférieure très larges pour la récurrence, puis à réduire l’intervalle d’incertitude. Ainsi,
on peut partir d’une borne inférieure T(n) = V(n) pour la récurrence (4.4) (car on a
le terme n dans la récurrence), puis montrer l’existence d’une borne supérieure initiale de T(n) = O(n2 ). Ensuite, on peut progressivement diminuer la borne supérieure
et augmenter la borne inférieure, pour finalement converger vers la bonne solution
asymptotiquement approchée qui est T(n) = Q(n lg n).
c) Subtilités
Il peut advenir que l’on arrive à conjecturer la borne asymptotique de la solution
d’une récurrence mais que l’on n’arrive pas, d’une façon ou d’une autre, à ajuster
les différentes valeurs au niveau de la récurrence. En général, le problème vient de
ce que l’hypothèse de récurrence n’est pas assez forte pour que l’on puisse valider
la borne précise. Quand on rencontre ce type d’obstacle, il suffit souvent de revoir
l’hypothèse, en y incluant la soustraction d’un terme d’ordre inférieur, pour que les
paramètres s’ajustent correctement.
Considérons la récurrence
T(n) = T( n/2 ) + T(n/2) + 1 .
On conjecture que la solution est O(n) et l’on essaye de montrer que T(n) cn
pour un choix approprié de la constante c. Quand on substitue la conjecture dans la
récurrence, on obtient
T(n) c n/2 + c n/2 + 1
= cn + 1 ,
4.1
Méthode de substitution
63
Ce qui n’implique pas que T(n) cn pour n’importe quel c. Il est tentant d’essayer
une expression d’ordre supérieur, par exemple T(n) = O(n2 ), qu’on choisit ad hoc.
Mais en fait, notre conjecture T(n) = O(n) est bonne. Toutefois, pour le démontrer, il
faut faire une hypothèse de récurrence plus forte.
Intuitivement, notre conjecture est presque bonne : on ne diverge que de la constante
1, qui est un terme d’ordre inférieur. Néanmoins, la récurrence mathématique ne
marchera que si nous prouvons la forme exacte de l’hypothèse de récurrence. On
contourne la difficulté en retouchant un terme d’ordre inférieur à notre précédente
hypothèse. Celle-ci devient T(n) cn − b, où b 0 est constant. On a maintenant :
T(n) (c n/2 − b) + (c n/2 − b) + 1
= cn − 2b + 1
cn − b ,
tant que b 1. Comme précédemment, la constante c doit être choisie suffisamment
grande pour que soient respectées les conditions aux limites.
La plupart des gens trouvent que l’idée de soustraire un terme d’ordre inférieur va
contre l’intuition. Mais, si le mécanisme mathématique ne fonctionne pas, pourquoi
est-ce que l’on ne renforcerait pas la conjecture ? La démarche repose sur le fait même
que l’on est en train d’utiliser la récurrence mathématique : pour prouver quelque
chose de plus fort pour une valeur donnée, on suppose quelque chose de plus fort
pour les valeurs plus petites.
d) Pièges
Il est facile de se fourvoyer dans l’emploi de la notation asymptotique. Par exemple,
dans la récurrence (4.4), on peut « démontrer » à tort que T(n) = O(n) en conjecturant
T(n) cn et en raisonnant de la façon suivante :
c Dunod – La photocopie non autorisée est un délit
T(n) 2(c n/2 ) + n
cn + n
= O(n) ,
⇐= faux!!
puisque c est une constante. L’erreur réside dans le fait que nous n’avons pas démontré la forme exacte de l’hypothèse de récurrence, à savoir que T(n) cn.
e) Changement de variable
Parfois, il suffit d’une petite manipulation algébrique pour faire ressembler une récurrence inconnue à une autre déjà vue. Considérons, par exemple, la récurrence
√
T(n) = 2T ( n ) + lg n
qui semble difficile. Il est pourtant possible de la simplifier via un changement de
variables. Pour se faciliter la tâche, on ne se préoccupera pas d’arrondir les valeurs
4 • Récurrences
64
√
comme n à l’entier le plus proche. Si l’on fait le changement de variable m = lg n,
on obtient
T(2m ) = 2T(2m/2 ) + m .
Si l’on pose maintenant S(m) = T(2m ) , on obtient la nouvelle récurrence
S(m) = 2S(m/2) + m ,
qui ressemble beaucoup à la récurrence (4.4). Effectivement, la nouvelle récurrence
a la même solution : S(m) = O(m lg m). En repassant de S(m) à T(n), on obtient
T(n) = T(2m ) = S(m) = O(m lg m) = O(lg n lg lg n).
Exercices
4.1.1 Montrer que la solution de T(n) = T(n/2) + 1 est O(lg n).
4.1.2 On a vu que la solution de T(n) = 2T( n/2 ) + n est O(n lg n). Montrer que la solution
de cette récurrence est aussi V(n lg n). En conclure que la solution estQ(n lg n).
4.1.3 Montrer que, en faisant une hypothèse de récurrence différente, on peut contourner la
difficulté posée par la condition aux limites T(1) = 1 pour la récurrence (4.4) sans modifier
les conditions aux limites pour la démonstration par récurrence.
4.1.4 Montrer que Q(n lg n) est la solution de la récurrence « exacte » (4.2) du tri par fusion.
4.1.5 Montrer que la solution de T(n) = 2T( n/2 + 17) + n est O(n lg n).
√
4.1.6 Résoudre la récurrence T(n) = 2T( n) + 1 en effectuant un changement de variable.
Votre solution doit être asymptotiquement approchée. Il ne faut pas se soucier de savoir si les
valeurs sont entières.
4.2 MÉTHODE DE L’ARBRE RÉCURSIF
La méthode de substitution peut fournir une preuve succincte de la validité d’une solution pour une récurrence, mais il est parfois ardu d’imaginer une bonne conjecture.
Tracer un arbre récursif, ainsi que nous l’avons fait dans notre analyse de la récurrence associée au tri par fusion (voir section 2.3.2), est un moyen direct d’arriver à
une bonne conjecture. Dans un arbre récursif, chaque nœud représente le coût d’un
sous-problème individuel, situé quelque part dans l’ensemble des invocations récursives de fonction. On totalise les coûts pour chaque niveau de l’arbre afin d’obtenir un
ensemble de coûts par niveau, puis l’on cumule tous les coûts par niveau pour calculer
le coût total de la récursivité tous niveaux confondus. Les arbres récursifs sont particulièrement utiles quand la récurrence décrit le temps d’exécution d’un algorithme
diviser-pour-régner.
4.2
Méthode de l’arbre récursif
65
Un arbre récursif s’utilise de préférence pour générer une bonne conjecture, confirmée ensuite via la méthode de substitution. Quand on emploie un arbre récursif pour
générer une bonne conjecture, on peut souvent tolérer une petite dose d’imprécision
puisque l’on vérifiera ultérieurement la conjecture. Néanmoins, si l’on trace l’arbre
récursif et que l’on cumule les coûts avec une très grande méticulosité, l’arbre fournit
une preuve directe d’une solution pour la récurrence. Dans cette section, nous utiliserons des arbres récursifs pour produire de bonnes conjectures, sachant qu’à la section
4.4 nous emploierons directement les arbres récursifs pour démontrer le théorème sur
lequel s’appuie la méthode générale.
Voyons, par exemple, comment un arbre récursif fournirait une bonne conjecture
pour la récurrence T(n) = 3T( n/4 ) + Q(n2 ). Commençons par nous occuper de
trouver une borne supérieure pour la solution. Comme nous savons que les parties
entières n’entrent généralement pas en ligne de compte pour la résolution des récurrences (voici un exemple d’approximation licite), nous créons un arbre récursif pour
la récurrence T(n) = 3T(n/4) + cn2 , dans laquelle nous avons explicité le coefficient
constant c > 0 implicite.
c Dunod – La photocopie non autorisée est un délit
La figure 4.1 montre le tracé progressif de l’arbre récursif associé à
T(n) = 3T(n/4) + cn2 . Pour des raisons de commodité, l’on suppose que n est une
puissance exacte de 4 (autre exemple d’approximation tolérable). La partie (a) de la
figure montre T(n), qui est ensuite développé en partie (b) pour devenir un arbre équivalent représentant la récurrence. Le terme cn2 situé sur la racine représente le coût
au niveau supérieur de la récursivité, et les trois sous-arbres de la racine représentent
les coûts induits par les sous-problèmes de taille n/4. La partie (c) montre le processus une étape plus loin, après expansion de chacun des nœuds de coût T(n/4) de la
partie (b). Le coût de chacun des trois enfants de la racine est c(n/4)2 . On continue
de développer chaque nœud de l’arbre en le décomposant en ses parties constitutives,
telles que déterminées par la récurrence.
Comme la taille du sous-problème décroît à mesure que l’on s’éloigne de la racine,
on finira par atteindre une condition aux limites. À quelle distance de la racine cela
se produira-t-il ? La taille de sous-problème pour un nœud situé à la profondeur i est
de n/4i . Donc, la taille de sous-problème prendra la valeur n = 1 quand n/4i = 1 ou,
de manière équivalente, quand i = log4 n. Par conséquent, l’arbre a log4 n + 1 niveaux
(0, 1, 2, . . . , log4 n).
Ensuite, on détermine le coût pour chaque niveau de l’arbre. Chaque niveau a
trois fois plus de nœuds que le niveau immédiatement supérieur, de sorte que le
nombre de nœuds de profondeur i est 3i . Comme la taille du sous-problème diminue
d’un facteur 4 quand on descend d’un niveau, chaque nœud de profondeur i, pour
i = 0, 1, 2, . . . , log4 n − 1, possède un coût de c(n/4i )2 . En multipliant, on voit que le
coût total pour l’ensemble des nœuds de profondeur i, pour i = 0, 1, 2, . . . , log4 n − 1,
vaut 3i c(n/4i )2 = (3/16)i cn2 . Le dernier niveau, situé à la profondeur log4 n,
a 3log4 n = nlog4 3 nœuds, qui ont chacun un coût T(1) et qui donnent un coût total
de nlog4 3 T(1), qui est Q(nlog4 3 ).
4 • Récurrences
66
cn2
T(n)
T ( n4 ) T ( n4 ) T ( n4 )
cn2
c ( n4 )2
c ( n4 )2
c ( n4 )2
n
n
n
n
n
n
n
n
n
T ( 16
) T ( 16
) T ( 16
) T ( 16
) T ( 16
) T ( 16
) T ( 16
) T ( 16
) T ( 16
)
(a)
(b)
(c)
cn2
cn2
c ( n4 )2
c ( n4 )2
3
16
n 2
n 2
n 2
c ( 16
) c ( 16
) c ( 16
)
n 2
n 2
n 2
c ( 16
) c ( 16
) c ( 16
)
n 2
n 2
n 2
c ( 16
) c ( 16
) c ( 16
)
3
( 16
) cn2
cn2
2
…
log4 n
c ( n4 )2
T(1) T(1) T(1) T(1) T(1) T(1) T(1) T(1) T(1) T(1) … T(1) T(1) T(1)
Q(nlog4 3 )
nlog4 3
(d)
Total : O(n2 )
Figure 4.1 La construction d’un arbre récursif pour la récurrence T(n) = 3T(n/4) + cn2 . La partie (a) montre T(n), progressivement développé dans les parties (b)–(d) pour former l’arbre récursif. L’arbre entièrement développé, en partie (d), a une hauteur de log4 n (il comprend log4 n + 1
niveaux).
On cumule maintenant les coûts de tous les niveaux, afin de calculer le coût global
de l’arbre :
3 log4 n−1
3 2 3 2 2
cn +
cn + · · · +
cn2 + Q(nlog4 3 )
T(n) = cn2 +
16
16
16
log4 n−1 3 i 2
cn + Q(nlog4 3 )
=
16
i=0
=
(3/16)log4 n − 1 2
cn + Q(nlog4 3 ) .
(3/16) − 1
4.2
Méthode de l’arbre récursif
67
Cette dernière formule paraît quelque peu tarabiscotée mais on peut, ici Aussi, utiliser une petite dose d’approximation pour borner supérieurement le résultat à l’aide
d’une série géométrique décroissante. En reprenant l’avant-dernière formule et en
appliquant l’équation (A.6), on obtient
log4 n−1 T(n) =
i=0
=
3 i
3 i 2
cn + Q(nlog4 3 ) <
cn2 + Q(nlog4 3 )
16
16
∞
i=0
16 2
1
cn2 + Q(nlog4 3 ) =
cn + Q(nlog4 3 ) = O(n2 ) .
1 − (3/16)
13
Nous avons donc déterminé une conjecture T(n) = O(n2 ) pour la récurrence originelle
T(n) = 3T( n/4 )+Q(n2 ). Dans cet exemple, les coefficients de cn2 forment une série
géométrique décroissante ; d’après l’équation (A.6), la somme de ces coefficients est
bornée supérieurement par la constante 16/13. Comme la contribution de la racine
au coût global est cn2 , la racine contribue pour une fraction constante du coût global.
En d’autres termes, le coût total de l’arbre est dominé par le coût de la racine.
En fait, si O(n2 ) est bien une borne supérieure pour la récurrence (comme nous le
vérifierons dans un moment), alors il doit s’agir d’une borne très approchée. Pourquoi
donc ? Le premier appel récursif contribue pour un coût de Q(n2 ), et donc V(n2 ) doit
être une borne inférieure pour la récurrence.
On peut maintenant employer la méthode de substitution pour vérifier la validité
de la conjecture, à savoir que T(n) = O(n2 ) est une borne supérieure de la récurrence
T(n) = 3T( n/4 ) + Q(n2 ). On veut montrer que T(n) dn2 pour une certaine
constante d > 0. En utilisant la même constante c > 0 que précédemment, on a
T(n) 3T( n/4 ) + cn2 3d n/4 2 + cn2
3
dn2 + cn2 dn2 ,
3d(n/4)2 + cn2 =
16
la dernière étape étant vraie si d (16/13)c.
c Dunod – La photocopie non autorisée est un délit
Autre exemple, plus complexe, celui de la figure 4.2 qui montre l’arbre récursif de
T(n) = T(n/3) + T(2n/3) + O(n) .
(Ici aussi, on omet les parties entières à des fins de simplification.) Comme précédemment, c représente le facteur constant dans le terme O(n). Quand on cumule les valeurs à travers les niveaux de l’arbre, on obtient une valeur cn pour chaque niveau. Le
plus long chemin menant de la racine à une feuille est n → (2/3)n → (2/3)2 n → · · ·
→ 1. Comme (2/3)k n = 1 quand k = log3/2 n, la hauteur de l’arbre est log3/2 n.
Intuitivement, on s’attend à ce que la solution de la récurrence soit au plus égale au
nombre de niveaux multiplié par le coût de chaque niveau, soit
O(cn log3/2 n) = O(n lg n). Le coût total est uniformément distribué à travers les niveaux de l’arbre. Mais il y a une complication ici : il faut aussi considérer le coût
4 • Récurrences
68
cn
c ( n3 )
cn
c ( 2n
3)
cn
log3/2 n
c ( n9 )
c ( 2n
9)
c ( 2n
9)
c ( 4n
9)
cn
…
…
Total : O(n lg n)
Figure 4.2 Un arbre récursif pour la récurrence T(n) = T(n/3) + T(2n/3) + cn.
des feuilles. Si l’arbre était un arbre binaire complet de hauteur log3/2 n, il y aurait 2log3/2 n = nlog3/2 2 feuilles. Comme le coût de chaque feuille est une constante,
le coût cumulé de toutes les feuilles serait alors Q(nlog3/2 2 ), qui est v(n lg n). Mais
cet arbre récursif n’est pas un arbre binaire complet, et donc il a moins de nlog3/2 2
feuilles. En outre, plus on s’éloigne de la racine, plus il y a de nœuds internes absents. Par conséquent, tous les niveaux ne contribuent pas pour un coût de cn ; les
niveaux inférieurs contribuent pour une valeur moindre. On pourrait comptabiliser
très précisément tous les coûts, mais rappelons-nous que l’on essaie tout simplement
d’arriver à une conjecture qui soit utilisable dans la méthode de substitution. Tolérons
donc l’approximation et essayons de montrer qu’une conjecture de O(n lg n) pour la
borne supérieure est correcte.
Effectivement, on peut utiliser la méthode de substitution pour vérifier que O(n lg n)
est une borne supérieure pour la solution de la récurrence. On montre que
T(n) dn lg n, où d est une constante positive idoine. On a
T(n) T(n/3) + T(2n/3) + cn
d(n/3) lg(n/3) + d(2n/3) lg(2n/3) + cn
= (d(n/3) lg n − d(n/3) lg 3)
+ (d(2n/3) lg n − d(2n/3) lg(3/2)) + cn
= dn lg n − d((n/3) lg 3 + (2n/3) lg(3/2)) + cn
= dn lg n − d((n/3) lg 3 + (2n/3) lg 3 − (2n/3) lg 2) + cn
= dn lg n − dn(lg 3 − 2/3) + cn
dn lg n ,
pour d c/(lg 3 − (2/3)). Il n’était donc point nécessaire de faire un compte plus
précis des coûts dans l’arbre récursif.
4.3
Méthode générale
69
Exercices
4.2.1 Utiliser un arbre récursif pour déterminer une bonne borne supérieure asymptotique
pour la récurrence T(n) = 3T( n/2 ) + n. Vérifier la réponse à l’aide de la méthode de substitution.
4.2.2 Démontrer, en faisant appel à un arbre récursif, que la solution de la récurrence
T(n) = T(n/3) + T(2n/3) + cn, où c est une constante, est V(n lg n).
4.2.3 Dessiner l’arbre récursif de T(n) = 4T( n/2 ) + cn, où c est une constante, puis fournir
une borne asymptotique approchée pour sa solution. Valider la borne à l’aide de la méthode
de substitution.
4.2.4 Utiliser un arbre récursif pour donner une solution asymptotiquement approchée de la
récurrence T(n) = T(n − a) + T(a) + cn, où a 1 et c > 0 sont des constantes.
4.2.5 Utiliser un arbre récursif pour donner une solution asymptotiquement approchée de la
récurrence T(n) = T(an) + T((1 − a)n) + cn, où a est une constante telle que 0 < a < 1 et
c > 0 est une constante.
4.3 MÉTHODE GÉNÉRALE
La méthode générale donne une « recette » pour résoudre les récurrences de la forme
T(n) = aT(n/b) + f (n) ,
(4.5)
c Dunod – La photocopie non autorisée est un délit
où a 1 et b > 1 sont des constantes, et f (n) une fonction asymptotiquement
positive. La méthode générale oblige à traiter trois cas de figure, mais permet de
déterminer la solution de nombreuses récurrences assez facilement.
La récurrence (4.5) décrit le temps d’exécution d’un algorithme qui divise un
problème de taille n en a sous-problèmes, chacun de taille n/b, a et b étant des
constantes positives. Les a sous-problèmes sont résolus récursivement, chacun dans
un temps T(n/b). Le coût induit par la décomposition du problème et la combinaison des résultats des sous-problèmes est décrit par la fonction f (n). (C’est-à-dire par
f (n) = D(n) + C(n), si l’on reprend la notation vue à la section 2.3.2.) Ainsi, la récurrence résultant de la procédure T RI -F USION donne a = 2, b = 2 et f (n) = Q(n).
Rigoureusement parlant, la récurrence n’est pas vraiment bien définie, puisque
n/b pourrait ne pas être un entier. Cependant, remplacer chacun des a termes T(n/b)
par T( n/b ) ou par T(n/b) n’affecte pas le comportement asymptotique de la récurrence. (Cette affirmation sera démontrée à la prochaine section.) On trouve donc
généralement commode d’omettre les parties entières quand on écrit des récurrences
diviser-pour-régner de cette forme.
4 • Récurrences
70
a) Théorème général
La méthode générale s’appuie sur le théorème suivant.
Théorème 4.1 (Théorème général.) Soient a 1 et b > 1 deux constantes, soit f (n)
une fonction et soit T(n) définie pour les entiers non négatifs par la récurrence
T(n) = aT(n/b) + f (n) ,
où l’on interprète n/b comme signifiant n/b ou n/b. T(n) peut alors être bornée
asymptotiquement de la façon suivante.
1) Si f (n) = O(nlogb a−´ ) pour une certaine constante ´ > 0, alors T(n) = Q(nlogb a ).
2) Si f (n) = Q(nlogb a ), alors T(n) = Q(nlogb a lg n).
3) Si f (n) = V(nlogb a+´ ) pour une certaine constante ´ > 0, et si af (n/b) cf (n)
pour une certaine constante c < 1 et pour tout n suffisamment grand, alors
T(n) = Q(f (n)).
Avant d’appliquer le théorème à des exemples, prenons un moment pour essayer
de comprendre sa signification. Dans chacun des trois cas, on compare la fonction
f (n) à la fonction nlogb a . Intuitivement, la solution de la récurrence est déterminée par
la plus grande des deux fonctions. Si c’est la fonction nlogb a qui est la plus grande
(cas 1), alors la solution est T(n) = Q(nlogb a ). Si c’est la fonction f (n) qui est la
plus grande (cas 3), alors la solution est T(n) = Q(f (n)). Si les deux fonctions ont
la même taille (cas 2), on multiplie par un facteur logarithmique et la solution est
T(n) = Q(nlogb a lg n) = Q(f (n) lg n).
Derrière cette intuition se cachent certaines finesses techniques. Dans le premier
cas, non seulement f (n) doit être plus petite que nlogb a , mais elle doit être plus petite polynomialement. Autrement dit, f (n) doit être asymptotiquement inférieure à
nlogb a d’un facteur n´ , pour une certaine constante ´ > 0. Dans le troisième cas, non
seulement f (n) doit être plus grande que nlogb a , mais elle doit être plus grande polynomialement et, en outre, satisfaire à la condition de « régularité » selon laquelle
af (n/b) cf (n). Cette condition est satisfaite par la plupart des fonctions polynomialement bornées que nous rencontrerons.
Il faut bien comprendre que les trois cas ne recouvrent pas toutes les possibilités
pour f (n). Il y a un fossé entre les cas 1 et 2, quand f (n) est plus petite que nlogb a
mais pas polynomialement plus petite. De même, il existe un fossé entre les cas 2
et 3 quand f (n) est plus grande que nlogb a mais pas polynomialement plus grande.
Si la fonction f (n) tombe dans l’un de ces fossés, ou si la condition de régularité du
cas 3 n’est pas vérifiée, alors la méthode générale ne peut pas servir à résoudre la
récurrence.
b) Utilisation de la méthode générale
Pour utiliser la méthode générale, on se contente de déterminer le cas du théorème
général qui s’applique (s’il y en a un) puis d’écrire la réponse.
4.3
Méthode générale
71
Comme premier exemple, considérons
T(n) = 9T(n/3) + n .
Pour cette récurrence, on a a = 9, b = 3 et f (n) = n ; on a donc nlogb a = nlog3 9 = Q(n2 ).
Puisque f (n) = O(nlog3 9−´ ), où ´ = 1, on peut appliquer le cas 1 du théorème général
et dire que la solution est T(n) = Q(n2 ).
Considérons à présent
T(n) = T(2n/3) + 1,
où a = 1, b = 3/2, f (n) = 1 et nlogb a = nlog3/2 1 = n0 = 1. On est dans le cas 2 puisque
f (n) = Q(nlogb a ) = Q(1), et donc la solution de la récurrence est T(n) = Q(lg n).
Pour la récurrence
T(n) = 3T(n/4) + n lg n ,
on a a = 3, b = 4, f (n) = n lg n et nlogb a = nlog4 3 = O(n0,793 ). Puisque f (n) = V(nlog4 3+´ ),
où ´ ≈ 0, 2, on est dans le cas 3 si l’on peut montrer que la condition de régularité
est vraie pour f (n). Pour n suffisamment grand,
af (n/b) = 3(n/4) lg(n/4) (3/4)n lg n = cf (n),
avec c = 3/4. Par conséquent, d’après le cas 3, la solution de la récurrence est
T(n) = Q(n lg n).
La méthode générale ne s’applique pas à la récurrence
T(n) = 2T(n/2) + n lg n ,
bien qu’elle ait la forme correcte : a = 2, b = 2, f (n) = n lg n et nlogb a = n. On pourrait
penser que le cas 3 s’applique, puisque f (n) = n lg n est plus grande asymptotiquement que nlogb a = n. Le problème est qu’elle n’est pas polynomialement plus grande.
Le rapport f (n)/nlogb a = (n lg n)/n = lg n est asymptotiquement plus petit que n´ pour
toute constante positive ´. En conséquence, la récurrence tombe dans le fossé situé
entre le cas 2 et le cas 3. (Voir exercice 4.4.2 pour une solution.)
c Dunod – La photocopie non autorisée est un délit
Exercices
4.3.1 Utiliser la méthode générale pour donner des bornes asymptotiques approchées pour
les récurrences suivantes.
a. T(n) = 4T(n/2) + n.
b. T(n) = 4T(n/2) + n2 .
c. T(n) = 4T(n/2) + n3 .
4.3.2 Le temps d’exécution d’un algorithme A est décrit par la récurrence T(n) = 7T(n/2)+n2 .
Un algorithme concurrent A a un temps d’exécution décrit par T (n) = aT (n/4) + n2 . Quelle
est la plus grande valeur entière de a telle que A soit asymptotiquement plus rapide que A ?
4 • Récurrences
72
4.3.3 Utiliser la méthode générale pour montrer que la solution de la récurrence
T(n) = T(n/2) + Q(1) associée à la recherche dichotomique) est T(n) = Q(lg n). (Voir exercice 2.3.5 pour une définition de la recherche dichotomique.)
4.3.4 La méthode générale est-elle applicable à la récurrence T(n) = 4T(n/2) + n2 lg n ?
Pourquoi ? Donner une borne supérieure asymptotique pour cette récurrence.
4.3.5 Considérez la condition de régularité af (n/b) cf (n), pour une certaine constante
c < 1, qui fait partie du cas 3 du théorème général. Donner un exemple de constantes a 1
et b > 1 et de fonction f (n) qui satisfasse à toutes les conditions du cas 3 du théorème général,
sauf à la condition de régularité.
4.4 DÉMONSTRATION DU THÉORÈME GÉNÉRAL
Cette section contient une démonstration du théorème général (théorème 4.1). Il n’est
pas nécessaire de comprendre la démonstration pour appliquer le théorème.
La preuve est divisée en deux parties. La première fait l’analyse de la récurrence
« générale » (4.5), en faisant l’hypothèse simplificatrice que T(n) n’est défini que sur
les puissances exactes de b > 1, autrement dit, pour n = 1, b, b2 , . . .. Cette partie
donne toute l’intuition nécessaire pour comprendre pourquoi le théorème général est
vrai. La seconde partie montre comment l’analyse peut être étendue pour tous les
entiers positifs n et est tout simplement une technique mathématique appliquée à la
gestion des parties entières.
Dans cette section, nous abuserons parfois légèrement de notre notation asymptotique en l’employant pour décrire le comportement de fonctions qui ne sont définies
que pour des puissances exactes de b. Il faut se souvenir que les définitions des notations asymptotiques imposent que les bornes soit démontrées pour tous les nombres
suffisamment grands, et pas seulement pour ceux qui sont puissances de b. Comme
on pourrait construire de nouvelles notations asymptotiques qui s’appliquent à l’ensemble {bi : i = 0, 1, . . .}, au lieu des entiers positifs, l’abus est mineur.
Néanmoins, on doit toujours rester sur ses gardes lorsqu’on utilise les notations
asymptotiques sur un domaine limité, de façon à ne pas tirer de conclusions hâtives.
Par exemple, démontrer que T(n) = O(n) quand n est une puissance exacte de 2 ne
garantit pas que T(n) = O(n). La fonction T(n) pourrait être définie par
n si n = 1, 2, 4, 8, . . . ,
T(n) =
n2 sinon ,
auquel cas la meilleure borne supérieure qu’on puisse trouver est T(n) = O(n2 ). À
cause de ce type de conséquences fâcheuses, on n’utilisera jamais la notation asymptotique sur un domaine limité sans faire en sorte que cette utilisation soit rendue
parfaitement claire par le contexte.
4.4
Démonstration du théorème général
73
4.4.1 Démonstration pour les puissances exactes
La première partie de la preuve du théorème général analyse la récurrence (4.5),
T(n) = aT(n/b) + f (n) ,
de la méthode générale en faisant l’hypothèse que n est une puissance exacte de
b > 1, où b n’est pas nécessairement entier. L’analyse se divise en trois lemmes.
Le premier réduit le problème de résolution de la récurrence générale à un problème
d’évaluation d’une expression qui contient une sommation. Le deuxième détermine
les bornes de cette sommation. Le troisième lemme rassemble les deux premiers pour
prouver une version du théorème général, dans le cas où n est une puissance exacte
de b.
Lemme 4.2 Soient a 1 et b > 1 deux constantes, et soit f (n) une fonction non
négative définie sur des puissances exactes de b. On définit T(n) pour des puissances
exactes de b par la récurrence
Q(1)
T(n) =
si n = 1 ,
aT(n/b) + f (n) si n = bi ,
où i est un entier positif. Alors :
logb n−1
T(n) = Q(n
logb a
)+
aj f (n/bj ) .
(4.6)
c Dunod – La photocopie non autorisée est un délit
j=0
Démonstration : Nous utiliserons l’arbre de la figure 4.3. La racine de l’arbre a le
coût f (n) et elle a a enfants, dont chacun a un coût f (n/b). (Il est commode de se représenter a comme un entier, surtout quand on visualise l’arbre, mais mathématiquement
cela n’est pas une obligation.) Chacun de ces enfants a a enfants de coût f (n/b2 ), et il
y a donc a2 nœuds à la distance 2 de la racine. Plus généralement, il y a aj nœuds à
la distance j de la racine, chacun ayant un coût f (n/bj ). Le coût de chaque feuille est
T(1) = Q(1), et chaque feuille est à la profondeur logb n, vu que n/blogb n = 1. Il y a
alogb n = nlogb a feuilles dans l’arbre.
On peut obtenir l’équation (4.6) en cumulant les coûts de chaque niveau de l’arbre,
comme le montre la figure. Le coût pour un niveau j de nœuds internes est aj f (n/bj ),
et donc le coût total de tous les niveaux internes de nœuds est
logb n−1
aj f (n/bj ) .
j=0
Dans l’algorithme diviser-pour-régner sous-jacent, ce cumul représente les coûts induits par la décompositions des problèmes en sous-problèmes et par le regroupement
ultérieur des sous-problèmes. Le coût de toutes les feuilles, qui est le coût cumulé de
tous les nlogb a sous-problèmes de taille 1, est Q(nlogb a ).
❑
4 • Récurrences
74
f (n)
f (n)
a
a
logb n
…
f (n/b)
af (n/b)
f (n/b)
a
a
f (n/b2 )f (n/b2 )…f (n/b2 ) f (n/b2 )f (n/b2 )…f (n/b2 )
f (n/b2 )f (n/b2 )…f (n/b2 )
a
a
a
a
a
a
a
a
a
…
…
…
…
…
…
…
…
…
Q(1) Q(1) Q(1) Q(1) Q(1) Q(1) Q(1) Q(1) Q(1) Q(1)
a2 f (n/b2 )
…
f (n/b)
… Q(1) Q(1) Q(1)
nlogb a
Q(nlogb a )
logb n−1
Total : Q(n
logb a
)+
aj f (n/bj )
j=0
Figure 4.3 L’arbre récursif généré par T(n) = aT(n/b) + f(n). L’arbre est un arbre complet d’arité
a ayant nlogb a feuilles et une hauteur logb n. Le coût de chaque niveau est montré à droite, et leur
somme est donnée dans l’équation (4.6).
En termes d’arbre récursif, les trois cas du théorème général correspondent aux cas
où le coût total de l’arbre est (1) dominé par les coûts des feuilles, (2) uniformément
distribué à travers les niveaux de l’arbre ou (3) dominé par le coût de la racine.
Dans l’équation (4.6), la sommation décrit le coût des étapes diviser et combiner
de l’algorithme diviser-pour-régner sous-jacent. Le lemme suivant donne des bornes
asymptotiques pour la croissance de la sommation.
Lemme 4.3 Soient a 1 et b > 1 deux constantes, et soit f (n) une fonction po-
sitive définie pour des puissances exactes de b. Une fonction g(n) définie pour des
puissances exactes de b par
logb n−1
g(n) =
aj f (n/bj )
(4.7)
j=0
peut être bornée asymptotiquement pour des puissances exactes de b de la manière
suivante :
1) Si f (n) = O(nlogb a−´ ) pour une certaine constante ´ > 0, alors g(n) = O(nlogb a ).
2) Si f (n) = Q(nlogb a ), alors g(n) = Q(nlogb a lg n).
3) Si af (n/b) cf (n) pour une certaine constante c < 1 et tous n b, alors
g(n) = Q(f (n)).
4.4
Démonstration du théorème général
75
Démonstration : Dans le cas 1, on a f (n) = O(nlogb a−´ ), ce qui donne f (n/bj ) =
O((n/bj )logb a−´ ). Après substitution dans l’équation (4.7), on obtient :
logb n−1
g(n) = O
aj
n logb a−´
j=0
(4.8)
.
bj
On borne l’équation à l’intérieur d’une notation O en factorisant les termes et en simplifiant, ce qui donne une série géométrique croissante :
logb n−1
aj
j=0
n logb a−´
bj
=
nlogb a−´
logb n−1 j=0
ab´ j
blogb a
logb n−1
=
nlogb a−´
(b´ )j
j=0
b´ logb n − 1
= nlogb a−´
b´ − 1
n´ − 1 = nlogb a−´ ´
.
b −1
Puisque b et ´ sont des constantes, la dernière expression se réduit à
nlogb a−´ O(n´ ) = O(nlogb a ).
Après remplacement de la sommation par cette expression dans l’équation (4.8), on
obtient
g(n) = O(nlogb a ) ,
et le cas 1 est prouvé.
En supposant que f (n) = Q(nlogb a ) pour le cas 2, on a f (n/bj ) = Q((n/bj )logb a ). En
substituant dans l’équation (4.7), on obtient :
logb n−1
g(n) = Q
c Dunod – La photocopie non autorisée est un délit
j=0
aj
n logb a
.
bj
(4.9)
Comme dans le cas 1, on borne la sommation avec Q, mais cette fois, on n’obtient pas
une série géométrique. On découvre en revanche que chaque terme de la sommation
est le même :
logb n−1 logb n−1
n logb a
a j
aj j
= nlogb a
log
b
b ba
j=0
j=0
logb n−1
=
nlogb a
1
j=0
=
nlogb a logb n .
En remplaçant par cette expression la sommation de l’équation (4.9), on obtient :
g(n) = Q(nlogb a logb n) = Q(nlogb a lg n) ,
et le cas 2 est prouvé.
4 • Récurrences
76
On démontre le cas 3 de la même manière. Puisque f (n) apparaît dans la définition (4.7) de g(n) et que tous les termes de g(n) sont non négatifs, on peut conclure
que g(n) = V(f (n)) pour des puissances exactes de b. D’après notre hypothèse selon
laquelle af (n/b) cf (n) pour une certaine constante c < 1 et tous les n b, on a
aj f (n/bj ) cj f (n). En itérant j fois, on a f (n/bj ) (c/a)j f (n) ou, de manière équivalent, aj f (n/bj ) cj f (n). Après substitution dans l’équation (4.7) et simplification,
on obtient une série géométrique, mais contrairement à celle du cas 1, celle-ci a des
termes décroissants :
logb n−1
g(n) aj f (n/bj )
j=0
logb n−1
cj f (n)
j=0
f (n)
∞
cj
j=0
=
=
1 f (n)
1−c
O(f (n)) ,
puisque c est constant. On peut donc conclure que g(n) = Q(f (n)) pour des puissances
exactes de b. Le cas 3 est prouvé, ce qui termine la démonstration du lemme.
❑
On peut prouver à présent une variante du théorème général pour le cas où n est
une puissance exacte de b.
Lemme 4.4 Soient a 1 et b > 1 des constantes, et soit f (n) une fonction non
négative définie pour des puissances exactes de b. On définit T(n) pour des puissances
exactes de b par la récurrence :
Q(1)
si n = 1 ,
T(n) =
aT(n/b) + f (n) si n = bi ,
où i est un entier positif. T(n) peut alors être borné asymptotiquement pour des puissances exactes de b, de la manière suivante :
1) Si f (n) = O(nlogb a−´ ) pour une certaine constante ´ > 0, alors T(n) = Q(nlogb a ).
2) Si f (n) = Q(nlogb a ), alors T(n) = Q(nlogb a lg n).
3) Si f (n) = V(nlogb a+´ ) pour une certaine constante ´ > 0, et si af (n/b) cf (n)
pour une certaine constante c < 1 et tous les n suffisamment grands, alors
T(n) = Q(f (n)).
Démonstration : On utilise les bornes du lemme 4.3 pour évaluer la sommation (4.6)
à partir du lemme 4.2. Pour le cas 1, on a
T(n) = Q(nlogb a ) + O(nlogb a )
= Q(nlogb a ) ,
4.4
Démonstration du théorème général
77
et pour le cas 2,
T(n) = Q(nlogb a ) + Q(nlogb a lg n)
= Q(nlogb a lg n) .
Pour le cas 3,
T(n) = Q(nlogb a ) + Q(f (n))
= Q(f (n)) ,
logb a+´
car f (n) = V(n
❑
).
4.4.2 Parties entières
Pour compléter la démonstration du théorème général, il faut maintenant étendre
notre analyse à la situation dans laquelle les parties entières sont utilisées dans la
récurrence générale, pour que cette récurrence soit définie pour tous les entiers, et
non plus uniquement pour les puissances exactes de b. L’obtention d’une borne inférieure pour
T(n) = aT(n/b) + f (n)
(4.10)
et d’une borne supérieure pour
T(n) = aT( n/b ) + f (n)
(4.11)
se fait sans problème, puisque la borne n/b n/b peut être obtenue à partir du premier cas, et que la borne n/b n/b peut être obtenue grâce au deuxième cas. La
technique permettant de borner par défaut la récurrence (4.11) est pratiquement identique à celle permettant de borner par excès la récurrence (4.10) ; on se contentera
donc de prouver l’existence de cette dernière borne.
c Dunod – La photocopie non autorisée est un délit
On modifie l’arbre récursif de la figure 4.3 pour produire l’arbre de la figure 4.4. À
mesure que l’on descend dans l’arbre, on obtient une suite d’invocations récursives
pour les arguments
n,
n/b ,
n/b /b ,
n/b /b /b ,
..
.
Notons ni le i-ème élément de la séquence, où
n
ni =
si i = 0 ,
ni−1 /b si i > 0 .
(4.12)
4 • Récurrences
78

Figure 4.4 L’arbre récursif généré par T(n) = aT(n/b) + f(n). L’argument récursif nj est donné
par l’équation (4.12).
Notre premier but est de déterminer le nombre d’itérations k telles que nk soit une
constante. A l’aide de l’inégalité x x + 1, on obtient
n0 n ,
n
+1,
n1 b
n 1
+ +1,
n2 b2 b
n
1 1
+
+ +1,
n3 b3 b2 b
..
.
D’une manière générale :
nj j−1
∞
i=0
i=0
n 1
n 1
n
b
.
+
<
+
= +
bj
bi
bj
bi bj b − 1
En faisant j = logb n , on obtient
nlogb n <
=
n
b
b
n
log n−1 +
b−1
b−1
b b
b
b
n
+
=b+
= O(1) ,
n/b b − 1
b−1
blogb n
+
4.4
Démonstration du théorème général
79
On voit donc qu’à la profondeur logb n , la taille de problème est au plus une
constante. La figure 4.4 montre que
logb n−1
T(n) = Q(n
logb a
)+
aj f (nj ) ,
(4.13)
j=0
qui est pratiquement identique à l’équation (4.6), hormis que n est un entier arbitraire
et qu’on ne lui impose pas d’être une puissance exacte de b.
Il est alors possible d’évaluer la sommation :
logb n−1
g(n) =
aj f (nj )
(4.14)
j=0
c Dunod – La photocopie non autorisée est un délit
à partir de (4.13) de manière analogue à la démonstration du lemme 4.3. En commençant par le cas 3, si af (n/b) cf (n) pour n > b + b/(b − 1), où c < 1
est une constante, alors aj f (nj ) cj f (n). En conséquence, la somme dans l’équation (4.14) peut être évaluée exactement comme dans le lemme 4.3. Pour le cas 2, on
a f (n) = Q(nlogb a ). Si l’on peut montrer que f (nj ) = O(nlogb a /aj ) = O((n/bj )logb a ),
alors la preuve pour le cas 2 du lemme 4.3 suivra directement. Il faut observer que
j logb n implique bj /n 1. La borne f (n) = O(nlogb a ) implique qu’il existe une
constante c > 0 telle que pour nj suffisamment grand,
n
b logb a
f (nj ) c j +
b b−1
b
bj
1+ ·
n b−1
=
c
n
bj
=
c
nlogb a
aj
c
nlogb a
aj
=
nlogb a
aj
O
logb a
1+
b
bj
·
n b−1
1+
b logb a
b−1
logb a
,
puisque c(1 + b/(b − 1))logb a est une constante. Le cas 2 est donc prouvé. La démonstration du cas 1 est pratiquement identique. Le principe est de démontrer que la
borne f (nj ) = O(nlogb a−´ ), ce qui équivaut à la démonstration correspondante dans le
cas 2, bien que l’expression soit algébriquement plus complexe.
Nous avons maintenant démontré l’existence des bornes supérieures dans le théorème général pour tous les entiers n. La démonstration pour les bornes inférieures est
similaire.
4 • Récurrences
80
Exercices
4.4.1 Donner une expression simple et exacte de ni dans l’équation (4.12) pour le cas où b
est un entier positif, et non un nombre réel arbitraire.
4.4.2 Montrer que si f (n) = Q(nlogb a lgk n), où k 0, alors la récurrence générale a pour
solution T(n) = Q(nlogb a lgk+1 n). Pour simplifier, on restreindra l’analyse aux puissances
exactes de b.
4.4.3 Montrer que le cas 3 du théorème général est redondant, au sens où la condition
de régularité af (n/b) cf (n) pour une certaine constante c < 1 implique qu’il existe une
constante ´ > 0 telle que f (n) = V(nlogb a+´ ).
PROBLÈMES
4.1. Exemples de récurrences
Donner des bornes asymptotiques supérieure et inférieure pour T(n) dans chacune
des récurrences suivantes. On supposera que T(n) est constant pour n 2. Rendre
les bornes aussi approchées que possible, et justifier les réponses.
a.
b.
c.
d.
e.
f.
g.
h.
T(n) = 2T(n/2) + n3 .
T(n) = T(9n/10) + n.
T(n) = 16T(n/4) + n2 .
T(n) = 7T(n/3) + n2 .
T(n) = 7T(n/2) + n2 .
√
T(n) = 2T(n/4) + n.
T(n) = T(n − 1) + n.
√
T(n) = T( n) + 1.
4.2. Trouver l’entier manquant
Un tableau A[1 . . n] contient tous les entiers de 0 à n, sauf un. Il serait facile de déterminer l’entier manquant en un temps O(n), à l’aide d’un tableau auxiliaire B[0 . . n]
servant à ranger les nombres qui apparaissent dans A. Dans ce problème, toutefois,
on ne peut pas accéder à un entier complet de A en une seule opération. Les éléments
de A sont représentés en binaire, et la seule opération possible pour y accéder est
« piocher le j-ème bit de A[i] », ce qui prend un temps constant.
Montrer que, si l’on utilise uniquement cette opération, on peut encore déterminer
l’entier manquant dans un temps O(n).
Problèmes
81
4.3. Coût du passage de paramètres
Tout au long de ce livre, on suppose que le passage de paramètres lors d’un appel
de procédure prend un temps constant, même si le paramètre est un tableau à N éléments. Cette supposition est valable sur la plupart des systèmes, car c’est un pointeur
sur le tableau qui est passé, et non le tableau lui-même. Ce problème examine les
conséquences de trois stratégies de passage de paramètre :
1) Un tableau est passé par pointeur. Temps = Q(1).
2) Un tableau est passé par copie. Temps = Q(N), où N est la taille du tableau.
3) Un tableau est passé via copie uniquement du sous-intervalle susceptible d’être
utilisé par la procédure appelée. Temps = Q(q − p + 1) si le sous-tableau A[p . . q]
est passé.
a. On considère l’algorithme récursif de recherche dichotomique consistant à trouver un nombre dans un tableau trié (voir exercice 2.3.5). Donner les récurrences
correspondant aux temps d’exécution du cas le plus défavorable de la recherche dichotomique, pour chacune des trois méthodes précédentes de passage de tableau,
et donner de bonnes bornes supérieures pour les solutions des récurrences. N est
la taille du problème initial et n la taille d’un sous-problème.
b. Refaire la partie (a) pour l’algorithme T RI -F USION de la section 2.3.1.
c Dunod – La photocopie non autorisée est un délit
4.4. Autres exemples de récurrences
Donner des bornes asymptotiques supérieure et inférieure pour T(n) dans chacune
des récurrences suivantes. On supposera que T(n) est constant pour n suffisamment
petit. Rendre les bornes aussi approchées que possible, et justifier les réponses.
a.
b.
c.
d.
e.
f.
g.
h.
i.
j.
T(n) = 3T(n/2) + n lg n.
T(n) = 5T(n/5) + n/ lg n.
√
T(n) = 4T(n/2) + n2 n.
T(n) = 3T(n/3 + 5) + n/2.
T(n) = 2T(n/2) + n/ lg n.
T(n) = T(n/2) + T(n/4) + T(n/8) + n.
T(n) = T(n − 1) + 1/n.
T(n) = T(n − 1) + lg n.
T(n) = T(n − 2) + 2 lg n.
√ √
T(n) = nT( n) + n.
4.5. Nombres de Fibonacci
Ce problème développe des propriétés des nombres de Fibonacci, qui sont définies
par la récurrence (3.21). On utilisera la technique des fonctions génératrices pour
4 • Récurrences
82
résoudre la récurrence de Fibonacci. On définit la fonction génératrice (ou série
formelle de puissances) F par :
F(z) =
∞
Fi zi
i=0
= 0 + z + z2 + 2z3 + 3z4 + 5z5 + 8z6 + 13z7 + 21z8 + · · · ,
où Fi est le i-ème nombre de Fibonacci.
a. Montrer que F(z) = z + zF(z) + z2 F(z).
b. Montrer que
z
F(z) =
1 − z − z2
z
=
(1 − fz)(1 − fz)
1
1
1
= √
−
1
−
fz
5
1 − fz
où
√
1+ 5
= 1,61803 . . .
f=
2
et
√
1− 5
= −0,61803 . . . .
f=
2
c. Montrer que
F(z) =
,
∞
1
i )zi .
√ (fi − f
5
i=0
√
d. Démontrer que Fi = fi / 5 pour i > 0, arrondi à l’entier le plus proche. (Conseil :
< 1.)
Observer que |f|
e. Démontrer que Fi+2 fi pour i 0.
4.6. Test de puces VLSI
Le Professeur Diogène dispose de n puces VLSI(1) , supposées identiques et théoriquement capables de se tester l’une l’autre. Le banc-test du professeur traite deux
puces à la fois. Quand le banc est chargé, chaque puce teste l’autre et indique si elle
est bonne ou mauvaise. Une bonne puce rapporte toujours correctement la qualité de
l’autre puce, mais on ne peut pas se fier à la réponse d’une mauvaise puce. Les quatre
résultats possibles pour un test sont donc :
(1) VLSI, acronyme de « very-large-scale integration » (intégration à très grande échelle) est la technologie
de circuit intégré utilisée pour fabriquer la plupart des microprocesseurs actuels.
Problèmes
La puce A dit
B est bonne
B est bonne
B est mauvaise
B est mauvaise
83
La puce B dit
A est bonne
A est mauvaise
A est bonne
A est mauvaise
Conclusion
les deux sont bonnes, ou les deux sont mauvaises
l’une au moins est mauvaise
l’une au moins est mauvaise
l’une au moins est mauvaise
a. Montrer que si plus de n/2 puces sont mauvaises, le professeur peut très bien ne
pas être en mesure de déterminer quelles sont les bonnes puces, s’il s’appuie sur
une stratégie fondée sur ce type de test de paire de puces. On supposera que les
mauvaises puces sont capables de conspirer pour induire le professeur en erreur.
b. On considère le problème consistant à trouver une bonne puce parmi n, en supposant que plus de n/2 puces sont bonnes. Montrer que n/2 tests de paire de puces
suffisent à réduire le problème de moitié environ.
c. Montrer que les bonnes puces peuvent être identifiées avec Q(n) tests de paire de
puces, en supposant que plus de n/2 puces sont bonnes. Donner et résoudre la
récurrence qui décrit le nombre de tests.
4.7. Tableaux de Monge
Un tableau A de réels de dimensions m × n est un tableau de Monge si, pour tout i,
j, k et l vérifiant 1 i < k m et 1 j < l n, on a
A[i, j] + A[k, l] A[i, l] + A[k, j] .
c Dunod – La photocopie non autorisée est un délit
En d’autres termes, chaque fois que l’on prend deux lignes et deux colonnes d’un
tableau de Monge et que l’on regarde les quatre éléments situés aux intersections
des lignes et des colonnes, la somme des éléments du coin supérieur gauche et du
coin inférieur droit est inférieure ou égale à la somme des éléments du coin inférieur
gauche et du coin supérieur droit. Voici un exemple de tableau de Monge :
10
17
24
11
45
36
75
17
22
28
13
44
33
66
13
16
22
6
32
19
51
28
29
34
17
37
21
53
23
23
24
7
23
6
34
a. Montrer qu’un tableau est un tableau de Monge si et seulement si, pour tout
i = 1, 2, ..., m − 1 et tout j = 1, 2, ..., n − 1, l’on a
A[i, j] + A[i + 1, j + 1] A[i, j + 1] + A[i + 1, j] .
(conseil : Pour la partie « si », utilisez la récurrence séparément sur les lignes et
sur les colonnes.)
4 • Récurrences
84
b. Le tableau suivant n’est pas un tableau de Monge. Modifiez un élément pour en
faire un tableau de Monge. (Conseil : Servez-vous de la partie (a).)
37
21
53
32
43
23 22 32
6 7 10
34 30 31
13 9 6
21 15 8
c. Soit f (i) l’indice de la colonne contenant l’élément minimal le plus à gauche de
la ligne i. Montrer que f (1) f (2) · · · f (m) pour un quelconque tableau de
Monge m × n.
d. Voici la description d’un algorithme diviser-pour-régner qui calcule l’élément minimal le plus à gauche pour chaque ligne d’un tableau de Monge m × n nommé A :
Construire une sous-matrice A of A composée des lignes de rang pair de A. Déterminer récursivement le minimum le plus à gauche pour chaque ligne de A .
Calculer ensuite le minimum le plus à gauche dans les lignes de rang impair de A.
Expliquer comment calculer le minimum le plus à gauche dans les lignes de rang
impair de A (à supposer que le minimum le plus à gauche des lignes de rang pair
soit connu) dans un temps O(m + n).
e. Écrire la récurrence décrivant le temps d’exécution de l’algorithme décrit en partie (d). Montrer que sa solution est O(m + n log m).
NOTES
Les récurrences ont été étudiées dès 1202 par L. Fibonacci, qui a laissé son nom aux nombres
de Fibonacci. A. De Moivre a introduit la méthode des fonctions génératrices (voir Problème 4.4.10) pour la résolution des récurrences. La méthode générale est adaptée d’après
Bentley, Haken et Saxe [41], qui donne la méthode complète justifiée par l’exercice 4.4.4.
Knuth [182] et Liu [205] montrent comment résoudre les récurrences linéaires à l’aide de
la méthode des fonctions génératrices. Purdom et Brown [252], ainsi que Graham, Knuth et
Patashnik [132] contiennent une vaste étude des récurrences.
Plusieurs chercheurs, dont Akra et Bazzi [13], Roura [262] et Verma [306] ont donné des
méthodes pour résoudre des récurrences diviser-pour-régner plus générales que celles résolues par la méthode générale. Voici le résultat de Akra et Bazzi, obtenu pour les récurrences
de la forme
k
ai T( n/bi ) + f (n) ,
(4.15)
T(n) =
i=1
où k 1 ; tous les coefficients ai sont positifs et leur somme vaut au moins 1 ; tous les bi sont
supérieurs à 1 ; f (n) est bornée, positive et non décroissante ; et, pour toute constante c > 1, il
existe des constantes n0 , d > 0 telles que f (n/c) df (n) pour tout n n0 . Cette méthode est
utilisable pour une récurrence du genre T(n) = T( n/3 ) + T( 2n/3 ) + O(n), pour laquelle
la méthode générale est inapplicable. Pour résoudre la recurrence (4.15), on commence par
Notes
85
trouver la valeur de p telle que ki=1 ai b−p
= 1. (Un tel p existe toujours, et il est unique et
i
positif.) La solution de la récurrence est alors
n
f (x)
dx ,
T(n) = Q(np ) + Q np
p+1
n x
c Dunod – La photocopie non autorisée est un délit
où n est une constante suffisamment grande. La méthode de Akra-Bazzi peut être quelque
peu délicate d’utilisation, mais permet de résoudre des récurrences qui modélisent la division
du problème en des sous-problèmes de tailles très inégales. La méthode générale est plus
simple d’utilisation, mais n’est applicable que si les tailles des sous-problèmes sont égales.
Chapitre 5
Analyse probabiliste
et algorithmes randomisés
Ce chapitre présente l’analyse probabiliste et les algorithmes randomisés. Si vous
êtes novice en matière de probabilités, lisez l’annexe C qui rappelle les notions fondamentales. Analyse probabiliste et algorithmes randomisés reviendront à plusieurs
reprises dans cet ouvrage.
c Dunod – La photocopie non autorisée est un délit
5.1 LE PROBLÈME DE L’EMBAUCHE
Supposez que vous deviez embaucher une nouvelle secrétaire par le truchement d’une
agence spécialisée. Cette agence vous envoie une candidate par jour. Vous interviewez cette personne pour savoir si vous allez l’embaucher ou non. L’agence prend des
honoraires minimes pour chaque candidate qu’elle vous envoie, sachant qu’une embauche revient plus cher : en effet, vous devez congédier la secrétaire actuelle et payer
des honoraires importants à l’agence. Comme vous avez décidé d’embaucher systématiquement la meilleure candidate, vous procédez de la manière suivante : après
chaque interview, si la postulante est plus qualifiée que la secrétaire actuelle, vous
renvoyez cette dernière pour engager la nouvelle candidate. Vous êtes prêt à payer le
prix induit par cette stratégie, mais vous voulez avoir une idée de ce prix.
La procédure E MBAUCHE -S ECRÉTAIRE, donnée ci-après, traduit en pseudo code
cette stratégie d’embauche. Les candidates pour le poste sont ici numérotées de 1
à n. La procédure suppose que vous êtes capable, après avoir interviewé la candidate i, de déterminer si cette candidate i est la meilleure postulante que vous avez
88
5 • Analyse probabiliste et algorithmes randomisés
vue jusqu’alors. À des fins d’initialisation, la procédure crée une candidate fictive,
numérotée 0, qui est moins qualifiée que toutes les autres candidates.
E MBAUCHE -S ECRÉTAIRE(n)
1 meilleure ← 0 candidate 0 est une candidate fictive, moins qualifiée
que quiconque
2 pour i ← 1 à n
3
faire interviewer candidate i
4
si candidate i supérieure à candidate meilleure
5
alors meilleure ← i
6
embaucher candidate i
Le modèle de coût de ce problème diffère du modèle présenté au chapitre 2. Nous
ne nous intéressons pas au temps d’exécution de E MBAUCHE -S ECRÉTAIRE, mais
plutôt au coût induit par l’interview et l’embauche. À priori, analyser le coût de cet
algorithme peut sembler très différent d’analyser le temps d’exécution d’une procédure telle que, disons, le tri par fusion. Les techniques analytiques employées sont
néanmoins les mêmes, qu’il s’agisse d’analyser le coût ou le temps d’exécution. Dans
l’un ou l’autre cas, nous comptons le nombre de fois que sont exécutées certaines
opérations de base.
Une interview a un coût minime ci , alors qu’une embauche a un coût ch bien plus
élevé. Soit m le nombre de personnes embauchées. Le coût total associé à cet algorithme est donc O(nci + mch ). Quel que soit le nombre de personnes que nous
embauchons, nous interviewons toujours n candidates et ainsi nous avons toujours le
même coût nci associé aux interviews. Nous allons, par conséquent, nous concentrer
sur l’analyse de mch , le coût d’embauche. Cette quantité varie avec chaque exécution
de l’algorithme.
Ce scénario sert de modèle à un paradigme informatique courant. Nous sommes
fréquemment amenés à trouver la valeur maximale ou minimale d’une suite en examinant chaque élément de la suite et en gérant un « élu » courant. Le problème de
l’embauche modélise le phénomène suivant : nous changeons plus ou moins souvent
d’opinion quant à notre estimation de l’élément que nous pensons être le meilleur.
a) Analyse du cas le plus défavorable
Dans le cas le plus défavorable, nous embauchons chaque candidate interviewée.
Cette situation se produit si les candidates se présentent dans l’ordre de qualité croissant, auquel cas nous embauchons n fois, pour un coût total de O(nch ).
Il est plus raisonnable, cependant, de s’attendre à ce que les candidates ne se
présenteront pas systématiquement dans l’ordre de qualité croissant. En fait, nous
n’avons aucune idée de l’ordre dans lequel elles viendront, et nous n’avons pas non
plus de contrôle sur cet ordre. Il est donc naturel de nous demander ce que nous
pensons qu’il adviendra dans un cas typique.
5.1
Le problème de l’embauche
89
b) Analyse probabiliste
Par analyse probabiliste, l’on entend l’utilisation des probabilités pour analyser les
problèmes. La plupart du temps, l’analyse probabiliste nous servira à étudier le temps
d’exécution d’un algorithme. Parfois, nous l’utiliserons pour étudier d’autres valeurs,
tel le coût d’embauche dans la procédure E MBAUCHE -S ECRÉTAIRE. Pour effectuer
une analyse probabiliste, nous devons nous baser sur la connaissance que nous avons
de la distribution des données en entrée, ou alors faire des hypothèses sur cette distribution. Ensuite nous analysons notre algorithme, en calculant un temps d’exécution
attendu. Comme l’espérance est prise sur la distribution des entrées possibles, nous
faisons en réalité la moyenne des temps d’exécution sur toutes les entrées potentielles.
Il nous faut être très prudent quant à notre estimation concernant la distribution des
entrées. Pour certains problèmes, il est raisonnable de faire des suppositions au sujet
de l’ensemble de toutes les entrées potentielles ; nous pouvons alors employer l’analyse probabiliste comme technique de conception d’algorithme efficace et comme
moyen de nous faire une meilleure idée d’un problème. Pour d’autres problèmes, en
revanche, nous ne pouvons pas décrire de distribution des entrées raisonnable ; en
pareil cas, nous ne pouvons pas nous servir de l’analyse probabiliste.
c Dunod – La photocopie non autorisée est un délit
Pour ce qui concerne le problème de l’embauche, nous pouvons supposer que
les postulantes se présentent dans un ordre aléatoire. Qu’est-ce que cela signifie
ici ? Nous partons du principe que nous pouvons faire une comparaison entre deux
candidates quelconques, puis décider quelle est la plus qualifiée ; il existe donc un
ordre total pour les candidates. (Voir l’annexe B pour le concept d’ordre total.) Nous
pouvons alors affecter à chaque candidate un numéro unique compris entre 1 et n,
rang(i) désignant ici le classement de la postulante i, et adopter la convention selon
laquelle plus le rang est élevé et plus la candidate est qualifiée. La liste ordonnée
rang(1), rang(2), . . . , rang(n) est une permutation de la liste 1, 2, . . . , n. Dire
que les candidates se présentent dans un ordre aléatoire revient à dire que la liste
des classements peut être l’une quelconque des n! permutations des nombres 1 à n.
Nous pouvons aussi exprimer ce fait en disant que les classements constituent une
permutation aléatoire uniforme ; cela veut dire que chacune des n! permutations
potentielles a la même probabilité d’apparition.
La section 5.2 contient une analyse probabiliste du problème de l’embauche.
c) Algorithmes randomisés
Pour pouvoir utiliser l’analyse probabiliste, nous devons connaître quelque chose
concernant la distribution relative aux données en entrée. Bien souvent, nous ne savons pas grand chose sur la distribution des entrées. Et même si nous savons quelque
chose, nous ne savons pas toujours modéliser informatiquement cette connaissance.
Il n’empêche que nous pouvons souvent faire appel aux probabilités et aux modèles
aléatoires pour nous aider à concevoir et analyser les algorithmes, et ce en contrôlant
une partie des aléas de l’algorithme.
90
5 • Analyse probabiliste et algorithmes randomisés
Dans le problème de l’embauche, on peut penser que les candidates nous sont
envoyées dans un ordre aléatoire, mais nous n’avons aucun moyen de savoir si tel
est bien le cas. Pour créer un algorithme randomisé pour le problème de l’embauche,
nous devons avoir un meilleur contrôle sur l’ordre d’interview des candidates. Nous
allons donc modifier légèrement le modèle. Nous allons supposer que l’agence nous
fait parvenir à l’avance la liste des n candidates. Chaque jour, nous choisissons au
hasard une candidate à interviewer. Nous ne connaissons rien des candidates (si ce
n’est leurs noms), mais nous avons procédé à un changement de taille. Au lieu de
supposer que les candidates viendront à nous dans un ordre aléatoire, nous contrôlons
maintenant le processus pour imposer un ordre véritablement aléatoire.
Plus généralement, nous dirons qu’un algorithme est randomisé si son comportement dépend non seulement des données en entrée mais aussi de valeurs produites
par un générateur de nombres aléatoires. Nous supposerons que nous disposons
d’un générateur de nombres aléatoires R ANDOM. Un appel à R ANDOM(a, b) donne
un entier compris entre a et b inclus, chacun des entiers possibles ayant la même probabilité d’apparition. Ainsi, R ANDOM(0, 1) donne 0 avec la probabilité 1/2 et 1 avec
la probabilité 1/2. R ANDOM(3, 7) donne 3, 4, 5, 6 ou 7, chacune de ces valeurs ayant
la probabilité 1/5. Chaque entier produit par R ANDOM est indépendant des entiers
produits par les appels précédents. Vous pouvez vous représenter R ANDOM comme
quelqu’un qui ferait rouler un dé à (b − a + 1) faces. (Dans la pratique, la plupart des
environnements de programmation offrent un générateur de nombres pseudo aléatoires, à savoir un algorithme déterministe produisant des nombres qui « ont l’air »
statistiquement aléatoires.)
Exercices
5.1.1 Montrer que l’hypothèse selon laquelle nous sommes constamment en mesure de déterminer la candidate optimale sur la ligne 4 de la procédure E MBAUCHE -S ECRÉTAIRE exige
que nous connaissions un ordre total concernant les classements des candidates.
5.1.2 Décrire une implémentation de la procédure R ANDOM(a, b) qui ne fait que des appels à R ANDOM(0, 1). Quel est le temps d’exécution attendu de votre procédure, en tant que
fonction de a et b ?
5.1.3 Supposez que vous vouliez produire 0 avec la probabilité 1/2 et 1 avec la probabilité 1/2. Vous disposez d’une procédure B IASED -R ANDOM , qui donne 0 ou 1. Elle produit
1 avec une certaine probabilité p et 0 avec la probabilité 1 − p (avec 0 < p < 1), mais
vous ne connaissez pas la valeur de p. Donnez un algorithme utilisant B IASED -R ANDOM
comme sous-routine qui produise une réponse non faussée, c’est-à-dire qui produise 0 avec
la probabilité 1/2 et 1 avec la probabilité 1/2. Quel est le temps d’exécution attendu de votre
algorithme, en tant que fonction de p ?
5.2
Variables indicatrices
91
5.2 VARIABLES INDICATRICES
Pour analyser moult algorithmes, dont le problème de l’embauche, nous utiliserons
des variables aléatoires indicatrices. Les variables aléatoires indicatrices offrent une
méthode commode pour faire la conversion entre probabilités et espérances. Soient
S un espace d’épreuves et A un événement. La variable indicatrice I {A} associée à
l’événement aléatoire A est alors définie par
1 si A se produit ,
I {A} =
(5.1)
0 si A ne se produit pas .
Comme exemple simple, calculons le nombre attendu de piles quand nous jetons
en l’air une pièce non faussée. Notre espace d’épreuves est S = {P, F}, et nous
définissons une variable aléatoire Y qui prend les valeurs P et F, dont chacune a la
probabilité 1/2. Nous pouvons alors définir une variable indicatrice XP , associée au
résultat pile, que nous pouvons exprimer comme étant l’événement Y = P. Cette
variable compte le nombre de piles obtenu dans ce lancer ; c’est 1 si la pièce donne
pile, 0 sinon. Nous écrivons
1 si Y = P ,
XP = I {Y = P} =
0 si Y = F .
Le nombre attendu de piles pour un seul lancer est tout simplement l’espérance de
notre variable indicatrice XP :
E [XP ] =
=
=
=
E [I {Y = P}]
1· Pr {Y = P} + 0· Pr {Y = F}
1·(1/2) + 0·(1/2)
1/2 .
c Dunod – La photocopie non autorisée est un délit
Le nombre attendu de piles dans un seul lancer d’une pièce non faussée est donc 1/2.
Ainsi que le montre le lemme suivant, l’espérance d’une variable indicatrice associée
à un événement aléatoire A est égale à la probabilité de A.
Lemme 5.1 Étant donnés un espace d’épreuves S et un événement A de l’espace S,
soit XA = I {A}. Alors, E [XA ] = Pr {A}.
Démonstration : D’après la définition d’une variable indicatrice, telle que donnée
dans l’équation (5.1), et d’après la définition de l’espérance, nous avons
E [XA ]
= E [I {A}]
= 1· Pr {A} + 0· Pr {A}
= Pr {A} ,
A désigne S − A, c’est-à-dire le complément de A.
❑
Les variables indicatrices peuvent paraître inutilement compliquées pour une application telle que le comptage du nombre attendu de piles pour un lancer d’une seule
92
5 • Analyse probabiliste et algorithmes randomisés
pièce, mais elles s’avèrent précieuses pour analyser des situations dans lesquelles on
effectue des essais aléatoires répétés. Par exemple, les variables indicatrices fournissent un moyen simple d’arriver au résultat de l’équation (C.36). Dans cette équation, on calcule le nombre de piles dans n lancers de pièce en considérant séparément la probabilité d’obtenir 0 pile, 1 pile, 2 piles, etc. Encore que la méthode plus
simple proposée dans l’équation (C.37) utilise, en fait, implicitement des variables
aléatoires indicatrices. Pour rendre cette discussion plus explicite, on peut définir Xi
comme étant la variable indicatrice associée à l’événement par lequel le i-ème lancer
donne pile. Si Yi est la variable aléatoire désignant le résultat du i-ème lancer, on a
Xi = I {Yi = P}. Soit X la variable aléatoire désignant le nombre total de piles dans
les n lancers, de sorte que
X=
n
Xi .
i=1
Comme on veut calculer le nombre attendu de piles, on prend l’espérance des deux
côtés de l’équation précédente afin d’obtenir
n
Xi .
E [X] = E
i=1
Le côté gauche de l’équation qui précède est l’espérance de la somme de n variables
aléatoires. D’après le lemme 5.1, nous pouvons facilement calculer l’espérance de
chacune des variables aléatoires. D’après l’équation (C.2) —relative à la linéarité de
l’espérance—, il est facile de calculer l’espérance de la somme : elle est égale à la
somme des espérances des n variables aléatoires. La linéarité de l’espérance fait de
l’emploi de variables indicatrices une puissante technique analytique ; elle s’applique
même en cas de dépendances entre les variables aléatoires. Nous pouvons maintenant
calculer facilement le nombre attendu de piles :
n
Xi
E [X] = E
i=1
=
=
n
i=1
n
E [Xi ]
1/2
i=1
= n/2 .
Ainsi, comparée à la méthode employée dans l’équation (C.36), les variables indicatrices simplifient grandement les calculs. Nous utiliserons des variables indicatrices
tout au long de ce livre.
5.2
Variables indicatrices
93
a) Problème de l’embauche et variables indicatrices
Revenons au problème de l’embauche ; nous voulons maintenant calculer le nombre
attendu de fois que nous embauchons une nouvelle secrétaire. Pour pouvoir faire une
analyse probabiliste, nous supposons que les candidates arrivent dans un ordre aléatoire, comme traité à la section précédente. (Nous verrons à la section 5.3 comment
nous débarrasser de cette hypothèse). Soit X la variable aléatoire dont la valeur est
égale au nombre de fois que nous engageons une secrétaire. Nous pourrions alors
appliquer la définition de l’espérance, telle que donnée dans l’équation (C.19), pour
obtenir
n
x Pr {X = x} ,
E [X] =
x=1
mais ce calcul serait lourd. Nous utiliserons plutôt des variables indicatrices pour
simplifier grandement le calcul.
Pour utiliser des variables indicatrices au lieu de calculer E [X] en définissant une
unique variable associée au nombre de fois que nous embauchons une secrétaire, nous
définirons n variables indiquant si chaque postulante individuelle est embauchée ou
non. En particulier, soit Xi la variable indicatrice associée à l’événement par lequel
on engage la i-ème candidate. Alors,
1 si candidate i est embauchée ,
Xi = I {candidate i est embauchée} =
0 si candidate i n’est pas embauchée ,
(5.2)
et
X = X1 + X2 + · · · + Xn .
(5.3)
D’après le lemme 5.1, nous avons
c Dunod – La photocopie non autorisée est un délit
E [Xi ] = Pr {candidate i est embauchée} ,
et nous devons donc calculer la probabilité d’exécution des lignes 5–6 de E MBAUCHE S ECRÉTAIRE.
La candidate i est embauchée (ligne 5) si et seulement si la candidate i est meilleure
que chacune des candidates 1 à i − 1. Comme nous sommes partis du principe que
les candidates arrivent dans un ordre aléatoire, les i premières candidates se sont
présentées dans un ordre aléatoire. Chacune de ces i premières candidates a la même
probabilité de meilleure qualification provisoire. La candidate i a une probabilité 1/i
d’être plus qualifiée que les candidates 1 à i − 1, et elle a donc une probabilité 1/i
d’être engagée. D’après le lemme 5.1, on en conclut que
E [Xi ] = 1/i .
(5.4)
5 • Analyse probabiliste et algorithmes randomisés
94
Nous pouvons maintenant calculer E [X] :
n
(d’après équation (5.3))
Xi
E [X] = E
(5.5)
i=1
=
=
n
i=1
n
E [Xi ]
(d’après linéarité de l’espérance)
1/i
(d’après équation (5.4))
i=1
= ln n + O(1) (d’après équation (A.7)) .
(5.6)
Même si nous interviewons n personnes, en moyenne nous n’en embauchons réellement qu’environ ln n. Nous résumerons ce résultat dans le lemme que voici.
Lemme 5.2 Si les candidates se présentent dans un ordre aléatoire, l’algorithme
E MBAUCHE -S ECRÉTAIRE a un coût total d’embauche O(ch ln n).
Démonstration : La borne découle directement de notre définition du coût d’embauche et de l’équation (5.6).
❑
Le coût d’embauche attendu représente une amélioration significative par rapport
au coût d’embauche du cas le plus défavorable qui est O(nch ).
Exercices
5.2.1 Dans E MBAUCHE -S ECRÉTAIRE, si les candidates se présentent dans un ordre aléatoire, quelle est la probabilité que vous n’embauchiez qu’une seule fois ? Quelle est la probabilité que vous embauchiez exactement n fois ?
5.2.2 Dans E MBAUCHE -S ECRÉTAIRE, si les candidates se présentent dans un ordre aléatoire, quelle est la probabilité que vous embauchiez exactement deux fois ?
5.2.3 Utilisez des variables aléatoires indicatrices pour calculer l’espérance de la somme de
n dés.
5.2.4 Utilisez des variables indicatrices pour résoudre le problème suivant, connu sous le
nom de problème du vestiaire à chapeaux. Chaque client parmi n au total donne son chapeau
à un employé d’un restaurant. Cet employé redonne les chapeaux aux clients dans un ordre
aléatoire. Quel est le nombre attendu de clients qui récupéreront leurs chapeaux ?
5.2.5 Soit A[1 . . n] un tableau de n nombres distincts. Si i < j et A[i] > A[j], on dit que la
paire (i, j) est une inversion de A. (Voir problème 2.4 pour plus de détails sur les inversions.)
On suppose que les éléments de A constituent une permutation uniforme aléatoire de l’intervalle 1 à n. Utilisez des variables indicatrices pour calculer le nombre attendu d’inversions.
5.3
Algorithmes randomisés
95
5.3 ALGORITHMES RANDOMISÉS
Dans la section précédente, nous avons montré comment le fait de connaître une
distribution concernant les entrées peut faciliter l’analyse du comportement moyen
d’un algorithme. Bien souvent, nous n’avons pas une telle connaissance et nous ne
pouvons faire aucune analyse de cas moyen. Comme mentionné à la section 5.1, nous
pourrons peut-être employer un algorithme randomisé.
c Dunod – La photocopie non autorisée est un délit
Pour un problème tel que celui de l’embauche, où cela aide de supposer que toutes
les permutations de l’entrée sont équiprobables, une analyse probabiliste guide la
création d’un algorithme randomisé. Au lieu de supposer qu’il y a telle ou telle distribution des entrées, nous imposons une distribution. En particulier, avant d’exécuter
l’algorithme, nous permutons de manière aléatoire les candidates de façon à forcer
l’équiprobabilité des diverses permutations. Cette modification ne change pas notre
espérance d’embaucher une secrétaire environ ln n fois. Elle signifie, toutefois, que
nous nous attendons à ce que cela soit le cas pour chaque entrée, et pas seulement
pour des données tirées d’une distribution particulière.
Nous allons maintenant étudier plus avant la distinction entre analyse probabiliste
et algorithmes randomisés. À la section 5.2, nous avons affirmé que, si les candidates
se présentent dans un ordre aléatoire, le nombre attendu de fois que nous embauchons
une secrétaire vaut environ ln n. Notez que l’algorithme ici est déterministe ; pour une
même entrée, quelle qu’elle soit, le nombre d’embauches d’une nouvelle secrétaire
est toujours le même. En outre, le nombre de fois que nous engageons une secrétaire
varie avec chaque entrée et dépend des classements des diverses candidates. Comme
ce nombre ne dépend que des classements des candidates, nous pouvons représenter
une entrée particulière par l’énumération ordonnée des classements des candidates,
c’est-à-dire rang(1), rang(2), . . . , rang(n). Avec la liste A1 = 1, 2, 3, 4, 5, 6, 7,
8, 9, 10, il y aura toujours 10 embauches successives, vu que chaque candidate est
meilleure que la précédente, et les lignes 5–6 seront exécutées pour chaque itération
de l’algorithme. Avec la liste A2 = 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, il n’y aura qu’une
seule embauche, faite dans la première itération. Avec la liste A3 = 5, 2, 1, 8, 4, 7,
10, 9, 3, 6, il y aura trois embauches, associées aux candidates 5, 8 et 10. Si nous
nous rappelons que le coût de notre algorithme dépend du nombre d’embauches, nous
voyons qu’il existe des entrées coûteuses, telle A1 , des entrées peu coûteuses, telle A2 ,
et des entrées modérément coûteuses, telle A3 .
Considérez, en revanche, l’algorithme randomisé qui commence par permuter les
candidates avant de déterminer la meilleure. Ici, la randomisation se situe dans l’algorithme, pas dans la distribution des entrées. Étant donnée une certaine entrée, disons
le A3 précédent, on ne peut pas dire combien de fois il y aura actualisation du maximum, car cette valeur varie avec chaque exécution de l’algorithme. La première fois
que nous exécutons l’algorithme sur A3 , il peut générer la permutation A1 et faire 10
actualisations ; la seconde fois que nous exécutons l’algorithme, nous pouvons produire la permutation A2 et faire une seule mise à jour. La troisième fois que nous
5 • Analyse probabiliste et algorithmes randomisés
96
l’exécutons, nous pouvons obtenir un autre nombre d’actualisations. À chaque exécution de l’algorithme, l’exécution dépend des choix aléatoires effectués et il y a de
fortes chances pour qu’elle diffère de la précédente exécution. Pour cet algorithme,
ainsi que pour nombre d’autres algorithmes randomisés, il n’existe aucune entrée
particulière qui engendre le comportement du cas le plus défavorable. Même votre
pire ennemi ne peut pas produire un tableau méchant en entrée, car la permutation
aléatoire ôte toute importance à l’ordre des entrées. L’algorithme randomisé ne donne
de mauvais résultats que si le générateur de nombres aléatoires génère une permutation « malheureuse ».
Pour le problème de l’embauche, la seule chose à modifier dans le code est la
permutation aléatoire du tableau.
E MBAUCHE - SECRÉTAIRE - RANDOMISÉ(n)
1 permuter aléatoirement la liste des candidates
2 meilleure ← 0 candidate 0 est une candidate fictive moins qualifiée
que quiconque
3 pour i ← 1 à n
4
faire interviewer candidate i
5
si candidate i supérieure à candidate meilleure
6
alors meilleure ← i
7
embaucher candidate i
Grâce à cette simple modification, nous avons créé un algorithme randomisé dont les
performances correspondent à celles obtenues dans l’hypothèse où les candidates se
présentent dans un ordre aléatoire.
Lemme 5.3 Le coût d’embauche attendu de la procédure E MBAUCHE -S ECRÉTAIRE -
R ANDOMISÉ est O(ch ln n).
Démonstration : Une fois permuté le tableau des entrées, nous sommes ramenés à
une situation identique à celle de l’analyse probabiliste de E MBAUCHE -S ECRÉTAIRE.
❑
La comparaison entre les lemmes 5.2 et 5.3 illustre la différence entre analyse
probabiliste et algorithmes randomisés. Dans le lemme 5.2, nous faisons une hypothèse concernant l’entrée. Dans le lemme 5.3, nous ne faisons aucune hypothèse de
ce genre, bien que la randomisation de l’entrée demande du temps supplémentaire.
Dans le reste de cette section, nous allons étudier certains problèmes induits par la
permutation aléatoire des entrées.
a) Permutation aléatoire de tableau
Nombreux sont les algorithmes randomisés qui randomisent l’entrée en permutant le
tableau initial. (Il existe d’autres façons d’employer la randomisation.) Nous allons
ici présenter deux méthodes en ce sens. Nous supposons que nous avons un tableau A
5.3
Algorithmes randomisés
97
contenant les éléments 1 à n (cela ne nuit pas à la généralité du problème). Notre but
est de produire une permutation aléatoire du tableau.
Une méthode courante consiste à assigner à chaque élément A[i] du tableau une
priorité aléatoire P[i], puis à trier les éléments de A selon ces priorités. Par exemple, si
notre tableau initial est A = 1, 2, 3, 4 et que nous choisissons les priorités aléatoires
P = 36, 3, 97, 19, nous produirons un tableau B = 2, 4, 1, 3 ; en effet, la deuxième
priorité est la plus faible, suivie de la quatrième, puis de la première, puis enfin de la
troisième. Nous baptiserons cette procédure P ERMUTE -PAR -T RI :
P ERMUTE -PAR -T RI(A)
1 n ← longueur[A]
2 pour i ← 1 à n
3
faire P[i] = R ANDOM(1, n3 )
4 trier A, avec P comme clés de tri
5 retourner A
Sur la ligne 3, il y a sélection d’un nombre aléatoire compris entre 1 et n3 . Nous
utilisons la plage 1 à n3 pour augmenter les chances d’unicité de chaque priorité
de P. (L’exercice 5.3.5 vous demandera de prouver que la probabilité que toutes les
priorités soient uniques est au moins de 1 − 1/n, alors que l’exercice 5.3.6 vous
demandera comment implémenter l’algorithme s’il existe deux ou plusieurs priorités
identiques.) Supposons ici que toutes les priorités sont uniques.
c Dunod – La photocopie non autorisée est un délit
La phase qui prend du temps dans cette procédure, c’est le tri sur la ligne 4. Comme
nous le verrons au chapitre 8, si nous employons un tri par comparaison, la durée du
tri est de V(n lg n). Nous pouvons arriver à cette limite inférieure, car nous avons
vu que le tri par fusion prend Q(n lg n). (Nous verrons, dans la partie 2, d’autres tris
par comparaison qui prennent Q(n lg n)). Après le tri, si P[i] est la j-ème priorité
en partant de la valeur minimale, alors A[i] est en position j dans le résultat. De cette
manière, nous obtenons une permutation. Reste à prouver que la procédure donne une
permutation aléatoire uniforme, c’est-à-dire que chaque permutation des nombres 1
à n a la même probabilité de génération.
Lemme 5.4 La procédure P ERMUTE -PAR -T RI produit une permutation aléatoire
uniforme de l’entrée, dans l’hypothèse où toutes les priorités sont distinctes.
Démonstration : Nous commencerons par considérer la permutation particulière
dans laquelle chaque élément A[i] reçoit la i-ème plus petite priorité. Nous montrerons que la probabilité d’apparition de cette permutation vaut exactement 1/n!. Pour
i = 1, 2, . . . , n soit Xi l’événement par lequel l’élément A[i] reçoit la i-ème plus petite priorité. Nous voulons alors calculer la probabilité de voir pour tout i se produire
l’événement Xi , probabilité qui est
Pr {X1 ∩ X2 ∩ X3 ∩ · · · ∩ Xn−1 ∩ Xn } .
98
5 • Analyse probabiliste et algorithmes randomisés
D’après l’exercice C.2.6 cette probabilité est égale à
Pr {X1 } · Pr {X2 | X1 } · Pr {X3 | X2 ∩ X1 } · Pr {X4 | X3 ∩ X2 ∩ X1 }
· · · Pr {Xi | Xi−1 ∩ Xi−2 ∩ · · · ∩ X1 } · · · Pr {Xn | Xn−1 ∩ · · · ∩ X1 } .
Nous avons Pr {X1 } = 1/n, car c’est la probabilité qu’une priorité prise au hasard dans un ensemble de n éléments soit la plus petite. Ensuite, nous observons
que Pr {X2 | X1 } = 1/(n − 1) ; en effet, comme l’élément A[1] a la priorité la
plus faible, chacun des n − 1 éléments restants a une chance égale d’avoir la
deuxième priorité la plus faible. Plus généralement, pour i = 2, 3, . . . , n, nous avons
Pr {Xi | Xi−1 ∩ Xi−2 ∩ · · · ∩ X1 } = 1/(n − i + 1) ; en effet, vu que les éléments A[1] à
A[i − 1] ont les i − 1 priorités les plus faibles (dans l’ordre), chacun des n − (i − 1) éléments restants a une chance égale d’avoir la i-ème plus petite priorité. Par conséquent,
nous avons
1 1 1 1
···
Pr {X1 ∩ X2 ∩ X3 ∩ · · · ∩ Xn−1 ∩ Xn } =
n
n−1
2
1
1
,
=
n!
et nous avons montré que la probabilité d’obtenir la permutation identité est de 1/n!.
Nous pouvons généraliser cette démonstration pour qu’elle fonctionne avec n’importe quelle permutation de priorités. Considérons une permutation fixée quelconque
s = s(1), s(2), . . . , s(n) de l’ensemble {1, 2, . . . , n}. Notons ri le rang de la priorité assignée à l’élément A[i], sachant que l’élément ayant la j-ème plus petite priorité
a le rang j. Si nous définissons Xi comme l’événement par lequel l’élément A[i] reçoit la s(i)-ème plus petite priorité, c’est-à-dire dans lequel ri = s(i), alors la même
démonstration s’applique encore. Si donc nous calculons la probabilité d’obtenir une
permutation particulière quelconque, le calcul est identique à celui qui précède, de
sorte que la probabilité d’obtenir cette permutation est aussi de 1/n!.
❑
On pourrait penser que, pour démontrer qu’une permutation est une permutation
aléatoire uniforme, il suffit de montrer que, pour chaque élément A[i], la probabilité
de le voir figurer en position j vaut 1/n. L’exercice 5.3.4 montre que cette condition
moins exigeante est, en fait, insuffisante.
Une meilleure méthode de génération d’une permutation aléatoire consiste à permuter directement le tableau initial. La procédure R ANDOMISATION -D IRECTE le
fait avec une durée O(n). Dans l’itération i, l’élément A[i] est choisi au hasard parmi
les éléments A[i] à A[n]. Après l’itération i, il n’y a plus jamais modification de A[i].
R ANDOMISATION -D IRECTE(A)
1 n ← longueur[A]
2 pour i ← 1 à n
3
faire échanger A[i] ↔ A[R ANDOM(i, n)]
Nous emploierons un invariant de boucle pour montrer que la procédure
R ANDOMISATION -D IRECTE produit une permutation aléatoire uniforme. Étant
donné un ensemble à n éléments, une k-permutation est une suite contenant k de ces
n éléments. (Voir annexe C) Il existe n!/(n − k)! possibilités de telles k-permutations.
5.3
Algorithmes randomisés
99
La procédure R ANDOMISATION -D IRECTE calcule une permutation
aléatoire uniforme.
Lemme 5.5
Démonstration : Nous utilisons l’invariant de boucle que voici :
Juste avant la i-ème itération de la boucle pour des lignes 2 –3, pour chaque
(i − 1)-permutation possible, le sous-tableau A[1 . . i − 1] contient cette (i − 1)permutation avec la probabilité (n − i + 1)!/n!.
Il faut démontrer que cet invariant est vrai avant la première itération de la boucle,
qu’il est conservé par chaque itération et qu’il fournit une propriété utile pour montrer
qu’il est vrai quand la boucle se termine.
Initialisation : Considérons la situation juste avant la première itération, donc quand
i = 1. L’invariant dit que, pour chaque 0-permutation possible, le sous-tableau
A[1 . . 0] contient cette 0-permutation avec la probabilité (n−i+1)!/n! = n!/n! = 1
Le sous-tableau A[1 . . 0] est vide, et une 0-permutation n’a pas d’éléments. Par
conséquent, A[1 . . 0] contient n’importe quelle 0-permutation avec la probabilité
1 ; l’invariant est donc vérifié avant la première itération.
Conservation : Nous supposons que, juste avant la (i − 1)-ème itération, chaque
(i − 1)-permutation possible apparaît dans le sous-tableau A[1 . . − 1] avec la probabilité (n − i + 1)!/n! ; nous allons montrer que, après la i-ème itération, chaque
i-permutation possible apparaît dans le sous-tableau A[1 . . i] avec la probabilité
(n − i)!/n!. L’incrémentation de i pour l’itération suivante conserve alors l’invariant.
Regardons la i-ème itération. Prenons une i-permutation particulière et notons
ses éléments par x1 , x2 , . . . , xi . Cette permutation se compose d’une (i − 1)permutation x1 , . . . , xi−1 , suivie de la valeur xi que l’algorithme place dans A[i].
Soit E1 l’événement par lequel les i − 1 premières itérations ont créé la (i − 1)permutation particulière x1 , . . . , xi−1 dans A[1 . . i − 1]. D’après l’invariant de
boucle, Pr {E1 } = (n − i + 1)!/n!. Soit E2 l’événement par lequel cette i-ème itération met xi dans la position A[i]. Comme la i-permutation x1 , . . . , xi est formée
dans A[1 . . i] uniquement quand se produisent E1 et E2 , nous voulons donc calculer
Pr {E2 ∩ E1 }. D’après l’équation (C.14) nous avons
c Dunod – La photocopie non autorisée est un délit
Pr {E2 ∩ E1 } = Pr {E2 | E1 } Pr {E1 } .
La probabilité Pr {E2 | E1 } vaut 1/(n − i + 1) ; en effet, sur la ligne 3, l’algorithme
choisit xi au hasard parmi les n − i + 1 valeurs qui occupent les positions A[i . . n].
Nous avons donc
Pr {E2 ∩ E1 } = Pr {E2 | E1 } Pr {E1 }
(n − i + 1)!
1
·
=
n−i+1
n!
(n − i)!
.
=
n!
Terminaison : À la fin de la boucle, i = n + 1, et nous avons le résultat
que le sous-tableau A[1 . . n] est une n-permutation donnée avec la probabilité
(n − n)!/n! = 1/n!.
En conclusion, R ANDOMISATION -D IRECTE produit une permutation aléatoire uniforme.
❑
100
5 • Analyse probabiliste et algorithmes randomisés
Un algorithme randomisé est souvent le moyen le plus simple et le plus efficace
de résoudre un problème. Dans ce livre, nous utiliserons de temps à autre des algorithmes randomisés.
Exercices
5.3.1 Le professeur Marceau conteste l’invariant de boucle utilisé dans la démonstration
du lemme 5.5. Il met en doute sa validité avant la première itération. Son raisonnement est
le suivant : on pourrait, tout aussi bien, déclarer qu’un sous-tableau vide ne contient pas
de 0-permutations. Par conséquent, la probabilité qu’un sous-tableau vide contienne une 0permutation doit être 0, ce qui invalide l’invariant de boucle avant la première itération. Réécrire la procédure R ANDOMISATION -D IRECTE de façon que l’invariant de boucle associé
s’applique à un sous-tableau non vide avant la première itération, et modifier la démonstration
du lemme 5.5 pour votre procédure.
5.3.2 Le professeur Kelp décide d’écrire une procédure qui produira aléatoirement une permutation différente en plus de la permutation identité. Il propose la procédure que voici :
P ERMUTE -S ANS -I DENTITÉ (A)
1 n ← longueur[A]
2 pour i ← 1 à n − 1
3
faire échanger A[i] ↔ A[R ANDOM(i + 1, n)]
Ce code fait-il ce que le professeur Kelp souhaite ?
5.3.3 Supposez que, au lieu d’échanger l’élément A[i] avec un élément aléatoire du soustableau A[i . . n], nous l’échangions avec un élément aléatoire pris n’importe où dans le tableau :
P ERMUTE -AVEC -T OUT (A)
1 n ← longueur[A]
2 pour i ← 1 à n
3
faire échanger A[i] ↔ A[R ANDOM(1, n)]
Ce code produit-il une permutation aléatoire uniforme ? Justifiez votre réponse ?
5.3.4 Le professeur Armstrong suggère la procédure suivante pour générer une permutation
aléatoire uniforme :
P ERMUTE -PAR -C YCLE (A)
1 n ← longueur[A]
2 aux ← R ANDOM(1, n)
3 pour i ← 1 à n
4
faire dec ← i + aux
5
si dec > n
6
alors dec ← dec −n
7
B[dec] ← A[i]
8 retourner B
5.4
Analyse probabiliste et autres emplois des variables indicatrices
101
Montrez que chaque élément A[i] a une probabilité 1/n de se retrouver à un certain emplacement, quel qu’il soit, de B. Montrez ensuite que le professeur Armstrong se trompe, en
prouvant que la permutation résultante n’est pas uniformément aléatoire.
5.3.5 Prouvez que, dans le tableau P de la procédure P ERMUTE -PAR -T RI, la probabilité
que tous les éléments soient uniques est au moins 1 − 1/n.
5.3.6 Expliquez comment implémenter l’algorithme P ERMUTE -PAR -T RI pour traiter le cas
où deux ou plusieurs priorités sont identiques. En d’autres termes, votre algorithme doit produire une permutation aléatoire uniforme même si deux ou plusieurs priorités sont identiques.
5.4 ANALYSE PROBABILISTE ET AUTRES EMPLOIS DES
VARIABLES INDICATRICES
Cette section avancée montre d’autres utilisations de l’analyse probabiliste, et ce sur
quatre exemples. Le premier détermine la probabilité que, dans une pièce contenant
k personnes, il se trouve deux personnes qui aient la même date de naissance. Le
deuxième exemple étudie le lancer aléatoire de ballons en direction de paniers. Le
troisième étudier les « suites » de piles successifs dans les lancers d’une pièce de
monnaie. Le dernier exemple étudie une variante du problème de l’embauche, dans
laquelle vous devez prendre des décisions sans interviewer réellement toutes les candidates.
c Dunod – La photocopie non autorisée est un délit
5.4.1 Le paradoxe des anniversaires
Notre premier exemple est le paradoxe des anniversaires. Combien doit-il y avoir
de gens dans une pièce pour qu’il y ait 50% de chances que deux personnes soient
nées le même jour de l’année ? Chose surprenante, la réponse est qu’il suffit d’un
petit nombre de personnes. Le paradoxe est que ce nombre est, en fait, très inférieur
au nombre de jours dans une année, voire à la moitié du nombre de jours dans une
année, comme nous allons le voir.
Pour répondre à cette question, nous indexons les personnes présentes dans la pièce
à l’aide des entiers 1, 2, . . . , k,où k est le nombre total de ces personnes. Nous ignorerons la problématique des années bissextiles et partirons du principe que toutes
les années ont n = 365 jours. Pour i = 1, 2, . . . , k, soit bi le jour anniversaire de la
personne i, sachant que 1 bi n. Nous supposerons également que les anniversaires sont distribués de manière uniforme sur les n jours de l’année, de sorte que
Pr {bi = r} = 1/n pour i = 1, 2, . . . , k et r = 1, 2, . . . , n.
La probabilité que deux personnes données, par exemple i et j, aient le même
anniversaire dépend de l’indépendance éventuelle de la sélection aléatoire des anniversaires. Nous supposerons dorénavant que les anniversaires sont indépendants, de
102
5 • Analyse probabiliste et algorithmes randomisés
sorte que la probabilité que l’anniversaire de i et celui de j tombent le jour r est
Pr {bi = r and bj = r} = Pr {bi = r} Pr {bj = r}
= 1/n2 .
Par conséquent, la probabilité que les deux anniversaires tombent le même jour est
Pr {bi = bj } =
=
n
r=1
n
Pr {bi = r et bj = r}
(1/n2 )
r=1
= 1/n .
(5.7)
De manière plus intuitive, une fois choisi bi , la probabilité que bj soit choisi de façon
à être le même jour de l’année est égale à 1/n. Ainsi, la probabilité que i et j aient
le même anniversaire est égale à la probabilité que l’anniversaire de l’une de ces
personnes tombe un certain jour de l’année. Notez, cependant, que cette coïncidence
dépend de l’hypothèse selon laquelle les anniversaires sont indépendants.
Pour déterminer la probabilité d’avoir au moins 2 personnes sur k qui aient le
même anniversaire, nous pouvons regarder l’événement complémentaire. La probabilité qu’il y ait au moins deux anniversaires identiques est égale à 1 moins la probabilité que tous les anniversaires soient différents. L’évènement dans lequel k personnes
ont des anniversaires distincts est
Bk =
k
Ai ,
i=1
où Ai est l’événement par lequel l’anniversaire de la personne i est différent de celui
de la personne j pour tout j < i. Comme nous pouvons écrire Bk = Ak ∩ Bk−1 , nous
obtenons d’après l’équation (C.16) la récurrence
Pr {Bk } = Pr {Bk−1 } Pr {Ak | Bk−1 } ,
(5.8)
où nous prenons Pr {B1 } = Pr {A1 } = 1 comme condition initiale. En d’autres termes,
la probabilité que b1 , b2 , . . . , bk soient des anniversaires distincts est égale à la probabilité que b1 , b2 , . . . , bk−1 soient des anniversaires distincts multipliée par la probabilité que bk fi bi pour i = 1, 2, . . . , k − 1, sachant que b1 , b2 , . . . , bk−1 sont distincts. Si b1 , b2 , . . . , bk−1 sont distincts, la probabilité conditionnelle que bk fi bi
pour i = 1, 2, . . . , k − 1 est égale à Pr {Ak | Bk−1 } = (n − k + 1)/n, vu que, sur les n
jours, il y en a n − (k − 1) qui ne sont pas pris. Nous appliquons de manière réitérée
5.4
Analyse probabiliste et autres emplois des variables indicatrices
103
la récurrence (5.8) pour obtenir
Pr {Bk } = Pr {Bk−1 } Pr {Ak | Bk−1 }
= Pr {Bk−2 } Pr {Ak−1 | Bk−2 } Pr {Ak | Bk−1 }
..
.
= Pr {B1 } Pr {A2 | B1 } Pr {A3 | B2 } · · · Pr {Ak | Bk−1 }
n − 1 n − 2 n − k + 1
···
= 1·
n
n
n
1
2
k − 1
= 1· 1 −
1−
··· 1 −
.
n
n
n
L’inégalité (3.11), 1 + x ex , nous donne
Pr {Bk } e−1/n e−2/n · · · e−(k−1)/n
k−1
= e− i=1 i/n
= e−k(k−1)/2n
1/2
quand −k(k − 1)/2n ln(1/2). La probabilité que tous les k anniversaires soient
distincts est au plus 1/2√quand k(k − 1) 2n ln 2 ou, en résolvant l’équation quadratique, quand k (1 + 1 + (8 ln 2)n)/2. Pour n = 365, nous devons avoir k 23.
Par conséquent, s’il y a au moins 23 personnes dans une pièce, la probabilité qu’il y
en ait au moins deux qui aient le même anniversaire est d’au moins 1/2. Sur Mars où
l’année fait 669 jours, il faut avoir 31 Martiens pour obtenir le même effet.
c Dunod – La photocopie non autorisée est un délit
a) Analyse via variables indicatrices
Nous pouvons employer des variables indicatrices pour fournir une analyse plus
simple, mais approximative, du paradoxe des anniversaires. Pour chaque paire (i, j)
formée parmi les k personnes, nous définissons la variable indicatrice Xij , pour
1 i < j k, de la façon suivante
Xij = I {personne i et personne j ont le même anniversaire}
1 si personne i et personne j ont le même anniversaire ,
=
0 sinon .
D’après l’équation (5.7), la probabilité que deux personnes aient le même anniversaire est de 1/n, et donc, d’après le lemme 5.1, nous avons
E [Xij ] = Pr {personne i et personne j ont le même anniversaire}
= 1/n .
104
5 • Analyse probabiliste et algorithmes randomisés
Si X est la variable aléatoire qui compte le nombre de paires d’individus ayant le
même anniversaire, nous avons
X=
k k
Xij .
i=1 j=i+1
En prenant les espérances des deux côtés et en appliquant la linéarité de l’espérance,
nous obtenons
k k
Xij
E [X] = E
i=1 j=i+1
=
k
k
E [Xij ]
i=1 j=i+1
k 1
2 n
k(k − 1)
.
=
2n
Ainsi, quand k(k − 1) 2n, le nombre attendu de paires de
√personnes ayant le même
anniversaire est au moins 1. Si donc nous avons au moins 2n + 1 individus dans une
pièce, nous pouvons espérer que deux personnes au moins auront le même anniversaire. Pour n = 365, si k = 28, le nombre attendu de paires ayant le même anniversaire
est (28·27)/(2·365) ≈ 1, 0356. Par conséquent, avec au moins 28 personnes, on peut
s’attendre à trouver au moins une paire de personnes ayant le même anniversaire. Sur
Mars où l’année fait 669 jours, il faut au moins 38 Martiens. La première analyse,
basée sur les seules probabilités, calculait le nombre de personnes requis pour que la
probabilité qu’il y ait une paire d’anniversaires identiques dépasse 1/2 ; la seconde
analyse, qui emploie des variables indicatrices, calcule le nombre de personnes requis pour que le nombre attendu d’anniversaires identiques soit égal à 1. Le nombre
exact de personnes n’est pas le même
√ dans les deux situations, mais les valeurs sont
les mêmes asymptotiquement : Q( n).
=
5.4.2 Ballons et paniers
Considérons le lancer aléatoire de ballons identiques vers b paniers numérotés
1, 2, . . . , b. Les lancers sont indépendants, et pour chaque lancer le ballon a la même
probabilité d’arriver dans l’un quelconque des paniers. La probabilité qu’un ballon
atterrisse dans un quelconque panier donné est 1/b. Le lancer des ballons est donc
une suite d’épreuves de Bernoulli (voir Annexe C.4) avec une probabilité 1/b de succès, succès signifiant ici que le ballon arrive dans le panier donné. Ce modèle est
particulièrement utile pour l’analyse du hachage (voir Chapitre 11), et nous pouvons
répondre à une foule de questions intéressantes concernant le processus du lancer de
5.4
Analyse probabiliste et autres emplois des variables indicatrices
105
ballons. (Le problème C.1 posera des questions supplémentaires sur les ballons et
paniers.)
Combien de ballons arrivent dans un panier donné ? Le nombre de ballons qui
tombent dans un panier donné obéit à la distribution binomiale b(k; n, 1/b). Si on
lance n ballons, l’équation (C.36) dit que le nombre attendu de ballons qui tombent
dans le panier donné est n/b.
Combien faut-il lancer de ballons, en moyenne, pour qu’un panier donné
contienne un ballon ? Le nombre de lancers à effectuer pour que le panier donné
reçoive un ballon obéit à la distribution géométrique avec une probabilité 1/b et,
d’après l’équation (C.31), le nombre attendu de lancers est 1/(1/b) = b.
Combien faut-il lancer de ballons pour que chaque panier contienne au moins un
ballon ? Appelons « coup au but » un lancer dans lequel un ballon tombe dans un
panier vide. Nous voulons connaître le nombre attendu de lancers n exigé pour avoir
b coups au but.
Les coups au but peuvent servir à diviser les n lancers en salves. La i-ème salve
regroupe les lancers qui suivent le (i − 1)-ème coup au but jusqu’au i-ème coup au
but. La première salve se compose du premier lancer, puisque nous sommes certains
d’avoir un coup au but quand tous les paniers sont vides. Pour chaque lancer de la
i-ème salve, il y a i − 1 paniers qui contiennent des ballons et b − i + 1 paniers vides.
Par conséquent, pour chaque lancer de la i-ème salve, la probabilité d’obtenir un coup
au but est (b − i + 1)/b.
Soit ni le nombre de lancers de la i-ème salve. Il s’ensuit que le nombre de lancers
b
requis pour avoir b coups au but est n =
i=1 ni . Chaque variable aléatoire ni a
une distribution géométrique avec la probabilité de succès (b − i + 1)/b et, d’après
l’équation (C.31),
E [ni ] =
b
.
b−i+1
c Dunod – La photocopie non autorisée est un délit
En raison de la linéarité de l’espérance,
b b
b
ni =
E [ni ] =
E [n] = E
i=1
i=1
i=1
1
b
=b
b−i+1
i
b
i=1
= b(ln b + O(1)) .
La dernière ligne découle de la borne (A.7) sur la série harmonique. Il faut donc
environ b ln b lancers pour que nous puissions espérer que chaque panier contienne
un ballon. Ce problème porte aussi le nom de problème du collecteur de coupons ;
il dit qu’une personne qui essaie de recueillir chacun de b coupons différents doit se
procurer environ b ln b coupons obtenus au hasard pour pouvoir réussir.
5 • Analyse probabiliste et algorithmes randomisés
106
5.4.3 Suites
Supposez que vous jetiez n fois en l’air une pièce non faussée. Quelle est la plus
longue suite de piles consécutifs que vous vous espérez voir ? La réponse est Q(lg n),
comme va le montrer l’étude qui suit.
Nous commencerons par prouver que la longueur attendue de la plus longue suite
de piles est O(lg n). La probabilité que chaque lancer de la pièce donne pile est 1/2.
Soit Aik l’événement par lequel une suite de piles de longueur au moins k commence
au i-ème lancer ou, plus précisément, l’événement par lequel les k lancers consécutifs
de la pièce i, i + 1, . . . , i + k − 1 donnent uniquement des piles, avec 1 k n et
1 i n − k + 1. Comme les lancers sont mutuellement indépendants, pour un
événement donné Aik , la probabilité que tous les k lancers produisent des piles est
Pr {Aik } = 1/2k .
(5.9)
Pour k = 2 lg n,
Pr {Ai,2lg n }
=
1/22lg n
1/22 lg n
=
1/n2 ,
et donc la probabilité qu’une suite de piles de longueur au moins égale à 2 lg n
commence à la position i est assez faible. Il existe au plus n − 2 lg n + 1 positions
où une telle suite peut commencer. La probabilité qu’une suite de piles de longueur
au moins égale à 2 lg n commence à un emplacement quelconque est donc


n−2lg n+1

n−2lg
n+1
Ai,2lg n
1/n2
Pr


i=1
i=1
<
n
1/n2
i=1
=
1/n ,
(5.10)
puisque, d’après l’inégalité de Boole (C.18), la probabilité d’une union d’événements
est au plus égale à la somme des probabilités des événements individuels. (Notez que
l’inégalité de Boole reste vraie pour des événements tels que ceux-là qui ne sont pas
indépendants.)
Nous employons maintenant l’inégalité (5.10) pour borner la longueur de la plus
longue suite. Pour j = 0, 1, 2, . . . , n, soit Lj l’événement par lequel la plus longue
suite de piles a une longueur d’exactement j, et soit L la longueur de la plus longue
suite. D’après la définition de l’espérance,
E [L] =
n
j=0
j Pr {Lj } .
(5.11)
5.4
Analyse probabiliste et autres emplois des variables indicatrices
107
Nous pourrions essayer d’évaluer cette somme en utilisant des bornes supérieures
sur chaque Pr {Lj } semblables à celles calculées dans l’inégalité (5.10). Hélas, cette
méthode donnerait des bornes faibles. Toutefois, nous pouvons utiliser une certaine
intuition, obtenue via l’analyse qui précède, pour avoir une borne correcte. De manière informelle, nous observons que, pour aucun des divers termes de la sommation
dans l’équation (5.11), les facteurs j et Pr {Lj } ne sont tous les deux grands en même
temps. Pourquoi ? Quand j 2 lg n, Pr {Lj } est très petit ; et quand j < 2 lg n,
j est assez petit. De manière plus formelle, nous remarquons que les événements Lj
pour j = 0, 1, . . . , n sont disjoints, et donc que la probabilité qu’unesuite de piles de
longueur au moins 2 lg n commence à un endroit quelconque est nj=2lg n Pr {Lj }.
D’après l’inégalité (5.10), nous avons nj=2lg n Pr {Lj } < 1/n. En outre, en remar2lg n−1
Pr {Lj } 1. Par conséquent, nous
quant que nj=0 Pr {Lj } = 1, nous avons j=0
obtenons
n
j Pr {Lj }
E [L] =
j=0
n
2lg n−1
=
j Pr {Lj } +
j=0
j Pr {Lj }
j=2lg n
n
2lg n−1
<
(2 lg n) Pr {Lj } +
j=0
2lg n−1
=
2 lg n
n Pr {Lj }
j=2lg n
Pr {Lj } + n
j=0
n
Pr {Lj }
j=2lg n
< 2 lg n·1 + n·(1/n)
= O(lg n) .
c Dunod – La photocopie non autorisée est un délit
Les chances qu’il y ait plus de r lg n piles d’affilée diminuent rapidement avec r.
Pour r 1, la probabilité qu’une suite de r lg n piles commence à la position i est
Pr {Ai,rlg n }
=
1/2rlg n
1/nr .
Ainsi, la probabilité est au plus de n/nr = 1/nr−1 que la plus longue suite fasse au
moins r lg n, ou dit autrement, la probabilité est au moins de 1 − 1/nr−1 que la
plus longue suite ait une longueur inférieure à r lg n.
À titre d’exemple, pour n = 1000 lancers de pièce, la probabilité d’avoir au moins
2 lg n = 20 piles d’affilée est au plus 1/n = 1/1000. Les chances d’avoir une suite
de piles plus longue que 3 lg n = 30 sont au plus 1/n2 = 1/1 000 000.
Nous allons maintenant prouver l’existence d’une borne inférieure complémentaire : la longueur attendue de la plus longue suite de piles dans n lancers de pièce
5 • Analyse probabiliste et algorithmes randomisés
108
est V(lg n). Pour démontrer la validité de cette borne, nous cherchons des suites de
longueur s en partitionnant les n lancers en environ n/s groupes de s lancers chacun.
Si nous choisissons s = (lg n)/2 , nous pouvons montrer qu’il est probable qu’un
de ces groupes au moins ne contienne que des piles, et donc qu’il est probable que la
plus longue suite ait une longueur d’au moins s = V(lg n). Nous montrerons ensuite
que la plus longue suite a une longueur attendue de V(lg n).
Nous partitionnons les n lancers en au moins n/ (lg n)/2 groupes de (lg n)/2
lancers consécutifs, et nous bornons la probabilité qu’il n’y ait aucun groupe ne contenant que des piles. D’après l’équation (5.9), la probabilité que le groupe commençant
à la position i ne contienne que des piles est
1/2(lg n)/2
√
1/ n .
Pr {Ai,(lg n)/2 }
=
La probabilité qu’une suite de piles de longueur
√ au moins (lg n)/2 ne commence
pas à la position i est donc au plus de 1 − 1/ n. Comme les n/ (lg n)/2 groupes
sont formés de lancers indépendants mutuellement exclusifs, la probabilité que chacun de ces groupes ne soit pas une suite de longueur (lg n)/2 est au plus
√ n/(lg n)/2
√ n/(lg n)/2−1
(1 − 1/ n)
(1 − 1/ n)
√ 2n/ lg n−1
(1 − 1/ n)
√
n
e−(2n/ lg n−1)/
=
O(e− lg n )
=
O(1/n) .
Pour cette discussion, nous nous sommes servis de l’inégalité
(3.11), 1 + x ex , et
√
du fait (que vous pourrez vérifier) que (2n/ lg n − 1)/ n lg n pour n suffisamment
grand.
Par conséquent, la probabilité que la plus longue suite ait une longueur supérieure
à (lg n)/2 est
n
Pr {Lj } 1 − O(1/n) .
(5.12)
j=(lg n)/2+1
Nous pouvons maintenant calculer une borne inférieure pour la longueur attendue de
la plus longue suite, en partant de l’équation (5.11) et en raisonnant d’une manière
5.4
Analyse probabiliste et autres emplois des variables indicatrices
109
semblable à notre analyse de la borne supérieure :
E [L]
=
n
j Pr {Lj }
j=0
(lg n)/2
=
n
j Pr {Lj } +
j=0
(lg n)/2
j Pr {Lj }
j=(lg n)/2+1
n
0· Pr {Lj } +
j=0
(lg n)/2 Pr {Lj }
j=(lg n)/2+1
(lg n)/2
=
0·
n
Pr {Lj } + (lg n)/2
j=0
Pr {Lj }
j=(lg n)/2+1
0 + (lg n)/2 (1 − O(1/n))
= V(lg n) .
(d’après l’inégalité (5.12))
Comme pour le paradoxe des anniversaires, nous pouvons obtenir une analyse plus
simple, mais approximative, en utilisant des variables indicatrices. Soit Xik = I {Aik }
la variable indicatrice associée à une suite de piles de longueur au moins k commençant au i-ème lancer. Pour compter le nombre total de telles suites, nous définissons
X=
n−k+1
Xik .
i=1
En prenant les espérances et en appliquant la linéarité de l’espérance, nous avons
n−k+1 Xik
E [X] = E
c Dunod – La photocopie non autorisée est un délit
=
=
=
i=1
n−k+1
E [Xik ]
i=1
n−k+1
i=1
n−k+1
Pr {Aik }
1/2k
i=1
=
n−k+1
.
2k
En appliquant ce résultat à différentes valeurs de k, nous pouvons calculer le
nombre attendu de suites de longueur k. Si ce nombre est grand (très supérieur
à 1),alors on peut s’attendre à voir moult suites de longueur k et la probabilité d’ap-
5 • Analyse probabiliste et algorithmes randomisés
110
parition d’une telle suite est élevée. Si ce nombre est faible (très inférieur à 1), alors
on peut s’attendre à ne voir que très peu de suites de longueur k et la probabilité
d’apparition d’une telle suite est réduite. Si k = c lg n, c étant une certaine constante
positive, on obtient
n − c lg n + 1
2c lg n
n − c lg n + 1
=
nc
1
(c lg n − 1)/n
=
−
c−1
n
nc−1
= Q(1/nc−1 ) .
E [X] =
Si c est grand, le nombre attendu de suites de longueur c lg n est très faible, et l’on
en conclut qu’il y a peu de chances de voir apparaître une telle suite. En revanche, si
c < 1/2, alors on obtient E [X] = Q(1/n1/2−1 ) = Q(n1/2 ), et l’on peut s’attendre a
voir un grand nombre de suites de longueur (1/2) lg n. Par conséquent, la probabilité
d’apparition d’une suite ayant cette longueur est très forte. À partir de ces seules
estimations, l’on peut déduire que la longueur attendue de la plus longue suite est
Q(lg n).
5.4.4 Le problème de l’embauche en ligne
Comme dernier exemple, nous allons considérer une variante du problème de l’embauche. On suppose désormais que l’on ne veut pas interviewer toutes les candidates
pour trouver la meilleure. On ne veut pas, non plus, passer son temps à embaucher et
congédier des candidates. On veut plutôt sélectionner une candidate proche de l’optimum, afin de ne procéder qu’à une seule embauche. L’entreprise impose la contrainte
suivante : après chaque entrevue, il faut soit proposer immédiatement le poste à la
candidate, soit lui dire qu’elle ne fait pas l’affaire. Quel est l’équilibre entre la minimisation du nombre d’entrevues et la maximisation de la qualité de la candidate
engagée ?
Nous pouvons modéliser le problème de la manière suivante. Après avoir vu une
postulante, nous lui donnons une note ; soit score(i) la note de la i-ème candidate,
en supposant que deux postulantes quelconques n’obtiennent jamais la même note.
Lorsque nous aurons vu j candidates, nous saurons laquelle des j a la meilleure note,
mais nous ne saurons pas si l’une des n − j candidates restantes serait susceptible
d’avoir une note encore meilleure. Nous décidons d’adopter la stratégie suivante :
nous sélectionnons un entier positif k < n, nous interviewons puis refusons les k
premières postulantes, et enfin nous engageons la première postulante suivante ayant
une note supérieure à toutes les candidates qui l’ont précédée. S’il s’avère que la
candidate la plus qualifiée figurait parmi les k premières interviewées, alors nous
embauchons la n-ème postulante. Cette stratégie est formalisée dans la procédure
5.4
Analyse probabiliste et autres emplois des variables indicatrices
111
M AXIMUM - EN - LIGNE(k, n), donnée ci-après. La procédure M AXIMUM - EN - LIGNE
retourne l’indice de la candidate que nous voulons engager.
M AXIMUM - EN - LIGNE(k, n)
1 meilleur score ← −∞
2 pour i ← 1 à k
3
faire si score(i) > meilleur score
4
alors meilleur score ← score(i)
5 pour i ← k + 1 à n
6
faire si score(i) > meilleur score
7
alors retourner i
8 retourner n
Nous voulons déterminer, pour chaque valeur possible de k, la probabilité d’engager la candidate la plus qualifiée. Nous choisirons ensuite la valeur optimale pour
k et appliquerons notre stratégie avec cette valeur. Pour l’instant, supposons k fixé.
Soit M(j) = max1 i j {score(i)} la note maximale parmi les postulantes 1 à j. Soit S
l’événement par lequel nous parvenons à sélectionner la candidate la plus qualifiée, et
est la i-ème interviewée. Comme
soit Si l’événement par lequel la candidate optimale les différents Si sont disjoints, nous avons Pr {S} = ni=1 Pr {Si }. En remarquant que
nous ne réussissons jamais quand la candidate la plus qualifiée figure parmi les k premières, nous avons Pr {Si } = 0 pour i = 1, 2, . . . , k. Par conséquent, nous obtenons
n
Pr {Si }
Pr {S} =
c Dunod – La photocopie non autorisée est un délit
i=k+1
Calculons maintenant Pr {Si }. Pour que nous réussissions quand la candidate optimale est la i-ème, il faut que se produisent deux choses. Primo, la candidate la
plus qualifiée doit être en position i, événement que nous noterons Bi . Secundo,
l’algorithme ne doit sélectionner aucune des postulantes k + 1 à i − 1, ce qui ne
se produit que si, pour chaque j tel que k + 1 j i − 1, nous trouvons que
score(j) < meilleur score sur la ligne 6. (Comme les notes sont uniques, nous ignorons la possibilité d’avoir score(j) = meilleur score.) En d’autres termes, il faut que
toutes les valeurs score(k + 1) à score(i − 1) soient inférieures à M(k) ; s’il y en a qui
sont plus grandes que M(k), à la place nous retournons l’indice de la première valeur
supérieure. Prenons Oi pour noter l’événement par lequel aucune des postulantes k+1
à i − 1 n’est choisie. Fort heureusement, les deux événements Bi et Oi sont indépendants. L’événement Oi dépend uniquement de l’ordre relatif des valeurs des positions
1 à i − 1, alors que Bi dépend uniquement de ce que la valeur en position i est ou
non supérieure aux valeurs de toutes les autres positions. L’ordre des valeurs des positions 1 à i − 1 n’affecte pas le fait que la valeur en position i est ou non plus grande
que toutes ces valeurs, et la valeur en position i n’affecte pas l’ordre des valeurs des
positions 1 à i − 1. Nous pouvons donc appliquer l’équation (C.15) pour obtenir
Pr {Si } = Pr {Bi ∩ Oi } = Pr {Bi } Pr {Oi } .
5 • Analyse probabiliste et algorithmes randomisés
112
La probabilité Pr {Bi } est visiblement 1/n, car le maximum a une chance égale
d’occuper l’une quelconque des n positions. Pour que se produise l’événement Oi ,
la valeur maximale des positions 1 à i − 1 doit être l’une des k premières positions,
et elle a une probabilité égale d’être l’une quelconque de ces i − 1 positions. Par
conséquent, Pr {Oi } = k/(i − 1) et Pr {Si } = k/(n(i − 1)). D’après l’équation (5.4.4),
nous avons
Pr {S} =
n
Pr {Si } =
i=k+1
n
i=k+1
n
n−1
k 1
k1
k
=
=
.
n(i − 1) n
i−1 n
i
i=k+1
i=k
Nous approximons par des intégrales pour encadrer cette sommation. D’après les
inégalités (A.12), nous avons
k
n
1
1
dx x
i
n−1
i=k
n−1
k−1
1
dx .
x
L’évaluation de ces intégrales définies nous donne les bornes
k
k
(ln n − ln k) Pr {S} (ln(n − 1) − ln(k − 1)) ,
n
n
qui fournissent un encadrement assez serré pour Pr {S}. Comme nous souhaitons
maximiser notre probabilité de succès, concentrons-nous sur le choix de la valeur
de k susceptible de maximiser la borne inférieure de Pr {S}. (D’ailleurs, il est plus
facile de maximiser l’expression de la borne inférieure que l’expression de la borne
supérieure.) En dérivant l’expression (k/n)(ln n − ln k) par rapport à k, nous obtenons
1
(ln n − ln k − 1) .
n
En rendant cette dérivée égale à 0, nous voyons que la borne inférieure de la probabilité est maximisée quand ln k = ln n − 1 = ln(n/e) ou, de manière équivalente, quand
k = n/e. Si donc nous mettons en œuvre notre stratégie avec k = n/e, nous réussirons
à embaucher notre candidate la plus qualifiée avec une probabilité d’au moins 1/e.
Exercices
5.4.1 Combien doit-il y avoir de personnes dans une pièce pour que la probabilité que quelqu’un ait le même anniversaire que vous soit au moins de 1/2 ? Combien doit-il y avoir de
personnes pour que la probabilité que deux personnes au moins aient leur anniversaire le 14
juillet soit supérieure à 1/2 ?
5.4.2 On jette des ballons dans b paniers. Chaque lancer est indépendant, et chaque ballon a
une chance égale de finir dans n’importe quel panier. Quel est le nombre attendu de lancers
pour qu’au moins l’un des paniers contienne deux ballons ?
Problèmes
113
5.4.3 Pour l’analyse du paradoxe des anniversaires, est-il important que les anniversaires
soient mutuellement indépendants, ou l’indépendance au niveau de la paire suffit-elle ? Justifiez votre réponse.
5.4.4 Combien de personnes faut-il inviter à une soirée pour que l’on ait des chances
d’avoir trois personnes ayant le même anniversaire ?
5.4.5 Quelle est la probabilité qu’une k-chaîne sur un ensemble de taille n soit en fait une
k-permutation ? En quoi cette question se rattache-t-elle au paradoxe des anniversaires ?
5.4.6 On lance n ballons dans n paniers, chaque lancer étant indépendant et le ballon ayant
une chance égale d’atterrir dans n’importe quel panier. Quel est le nombre attendu de paniers
vides ? Quel est le nombre attendu de paniers contenant exactement un ballon ?
5.4.7 Affinez la borne inférieure de la longueur de la suite en montrant que, dans n lancers
d’une pièce non faussée, la probabilité qu’il n’y ait aucune suite de piles consécutifs plus
longue que lg n − 2 lg lg n est inférieure à 1/n.
c Dunod – La photocopie non autorisée est un délit
PROBLÈMES
5.1. Comptage probabiliste
Un compteur de b bits permet, en principe, de compter jusqu’à 2b − 1. Le comptage
probabiliste de R. Morris permet d’aller beaucoup plus loin, au prix d’une certaine
perte de précision.
Une valeur de compteur i représente un comptage de ni pour i = 0, 1, . . . , 2b − 1,
les ni formant une suite croissante de valeurs non négatives. On suppose que la valeur
initiale du compteur est 0, représentant un comptage de n0 = 0. L’opération I NCRE MENT opère sur un compteur contenant la valeur i d’une manière probabiliste. Si
i = 2b − 1, alors il y a erreur de dépassement de capacité. Autrement, le compteur soit
augmente de 1 avec la probabilité 1/(ni+1 − ni ), soit reste inchangé avec la probabilité
1 − 1/(ni+1 − ni ).
Si l’on prend ni = i pour tout i 0, alors le compteur est un compteur classique.
Les choses deviennent plus intéressantes si l’on prend, disons, ni = 2i−1 pour i > 0
ou ni = Fi (i-ème nombre de Fibonacci—voir Section 3.2).
Pour ce problème, on suppose que n2b −1 est suffisamment grand pour que la probabilité d’une erreur de dépassement de capacité soit négligeable.
a. Montrez que la valeur attendue représentée par le compteur après n opérations
I NCREMENT est exactement n.
b. L’analyse de la variance du comptage représenté par le compteur dépend de la
suite des ni . Prenons un cas simple : ni = 100i pour tout i 0. Donnez une
estimation de la variance de la valeur représentée par le registre après n opérations
I NCREMENT.
114
5 • Analyse probabiliste et algorithmes randomisés
5.2. Recherche dans un tableau non trié
Ce problème étudie trois algorithmes relatifs à la recherche d’une valeur x dans un
tableau non trié A à n éléments.
Considérons la stratégie randomisée suivante : on prend au hasard un indice i
pour A. Si A[i] = x, alors le traitement se termine ; sinon, on continue la recherche
en prenant au hasard un nouvel indice pour A. On continue à prendre au hasard des
indices pour A jusqu’à ce que l’on trouve un indice j tel que A[j] = x, ou jusqu’à ce
que l’on ait testé chaque élément de A. Notez ceci : comme nous piochons chaque
fois dans tout l’ensemble des indices, nous risquons d’examiner un certain élément
plus d’une fois.
a. Écrivez le pseudo code d’une procédure R ECHERCHE -A LÉATOIRE qui mette en
œuvre la stratégie précédemment exposée. Vérifiez que votre algorithme se termine quand on a pioché tous les indices de A.
b. On suppose qu’il y a exactement un seul indice i tel que A[i] = x. Quel est le
nombre attendu d’indices de A qu’il faut piocher pour que la recherche de x aboutisse et que la procédure R ECHERCHE -A LÉATOIRE prenne fin ?
c. Afin de généraliser la solution de la partie (b), on suppose qu’il y a k 1 indices i
tels que A[i] = x. Quel est le nombre attendu d’indices de A qu’il faut piocher pour
que la recherche de x aboutisse et que la procédure R ECHERCHE -A LÉATOIRE
prenne fin ? La réponse doit être une fonction de n et k.
d. On suppose qu’il n’existe aucun indice i tel que A[i] = x. Quel est le nombre
attendu d’indices de A qu’il faut piocher pour que tous les éléments de A aient été
testés et que la procédure R ECHERCHE -A LÉATOIRE prenne fin ?
Considérons maintenant un algorithme de recherche linéaire déterministe, que nous
appellerons R ECHERCHE -D ÉTERMINISTE. L’algorithme parcourt A dans l’ordre
pour trouver x, testant successivement A[1], A[2], A[3], . . . , A[n] jusqu’à ce que soit
vérifié A[i] = x ou que soit atteinte la fin du tableau. On admet l’équiprobabilité de
toutes les permutations potentielles du tableau donné en entrée.
e. On suppose qu’il y a exactement un seul indice i tel que A[i] = x. Quel est le
temps d’exécution attendu pour R ECHERCHE -D ÉTERMINISTE ? Quel est le temps
d’exécution de R ECHERCHE -D ÉTERMINISTE dans le cas le plus défavorable ?
f. Afin de généraliser la solution de la partie (e), on suppose qu’il existe k 1 indices
i tels que A[i] = x. Quel est le temps d’exécution attendu pour R ECHERCHE D ÉTERMINISTE ? Quel est le temps d’exécution de R ECHERCHE -D ÉTERMINISTE
dans le cas le plus défavorable ? La réponse doit être une fonction de n et k.
g. On suppose qu’il n’existe aucun indice i tel que A[i] = x. Quel est le temps d’exécution attendu pour R ECHERCHE -D ÉTERMINISTE ? Quel est le temps d’exécution
de R ECHERCHE -D ÉTERMINISTE dans le cas le plus défavorable ?
Notes
115
Considérons enfin un algorithme randomisé R ECHERCHE -M ÉLANGE qui commence
par permuter de manière aléatoire le tableau initial, puis qui lance la recherche linéaire déterministe, précédemment exposée, sur le tableau permuté résultant.
h. k désignant le nombre d’indices i tels que A[i] = x, donnez le temps d’exécution
attendu et le temps d’exécution du cas le plus défavorable pour R ECHERCHE M ÉLANGE dans les cas k = 0 et k = 1. Généralisez votre solution de façon qu’elle
intègre le cas k 1.
i. Quel est celui des trois algorithmes de recherche que vous utiliseriez ? Expliquez
votre réponse.
NOTES
c Dunod – La photocopie non autorisée est un délit
Bollobás [44], Hofri [151] et Spencer [283] contiennent une foule de techniques probabilistes avancées. Les avantages des algorithmes randomisés sont traités par Karp [174] et Rabin [253]. Le manuel de Motwani et Raghavan [228] offre un exposé très complet sur les
algorithmes randomisés.
Le problème de l’embauche, à travers diverses variantes, a donné lieu à une multitude
d’études. Ces problèmes sont généralement connus sous l’appellation de « problèmes des
secrétaires ». Le papier de Ajtai, Meggido et Waarts [12] présente un exemple de ce genre
d’étude.
PARTIE 2
TRI ET RANGS
Cette partie présente plusieurs algorithmes capables de résoudre le problème du tri,
qu’on définit ainsi :
Entrée : Une séquence de n nombres a1 , a2 , . . . , an .
Sortie : Une permutation (réarrangement) a1 , a2 , . . . , an de la séquence d’entrée
telle que a1 a2 · · · an .
La séquence d’entrée est en général un tableau à n éléments, bien qu’il puisse être
également représenté différemment, par exemple sous la forme d’une liste chaînée.
c Dunod – La photocopie non autorisée est un délit
Structure des données
En pratique, les nombres à trier sont rarement des valeurs isolées. Chacun fait généralement partie d’une collection de données appelée enregistrement. Chaque enregistrement contient une clé, qui est la valeur à trier, et le reste de l’enregistrement
est constitué de données satellites, qui sont en général traitées en même temps que la
clé. En pratique, lorsqu’un algorithme de tri permute les clés, il doit permuter également les données satellites. Si chaque enregistrement contient une grande quantité de
données satellites, on permute souvent un tableau de pointeurs pointant vers les enregistrements, et non les enregistrements eux-mêmes, pour minimiser les déplacements
de données.
En un sens, ce sont ces détails d’implémentation qui distinguent un algorithme
d’un programme complet. Le fait qu’on trie des nombres isolés ou de grands enregistrements contenant des nombres n’a pas de conséquence sur la méthode avec laquelle
118
Partie 2
une procédure de tri détermine l’ordre de tri. Donc, quand on s’intéresse à un problème de tri, on suppose en général que l’entrée n’est constituée que de nombres. La
transposition d’un algorithme triant des nombres en programme triant des enregistrements est conceptuellement immédiate, bien que dans certaines situations concrètes,
on puisse rencontrer d’autres subtilités qui rendent périlleuse la tâche de programmation réelle.
Pourquoi trier ?
Nombre de théoriciens de l’informatique considèrent le tri comme le problème le
plus fondamental en matière d’algorithmique. Et ce pour plusieurs raisons :
– Il peut advenir que le besoin de trier les données soit inhérent à l’application. Ainsi,
pour établir les relevés bancaires de leurs clients, les banques trient les chèques par
leurs numéros.
– Les algorithmes utilisent souvent le tri en tant que sous-routine vitale. Par exemple,
un programme qui dessine des objets graphiques empilés les uns par dessus les
autres doit trier les objets selon une relation de « recouvrement » qui lui permette
de dessiner les objets en partant du bas et en remontant vers le haut. Nombre d’algorithmes de cet ouvrage emploient une sous-routine de tri.
– Il existe une foule d’algorithmes de tri, qui emploient une riche panoplie de techniques. En fait, maintes techniques majeures de l’algorithmique sont représentées
sous la forme d’algorithmes de tri qui ont été développés au fil des ans. Vu de cette
façon, le tri présente donc aussi un intérêt historique.
– Le tri est un problème pour lequel on peut trouver un minorant non trivial (comme
nous le ferons au chapitre 8). Comme nos meilleurs majorants convergent asymptotiquement vers le minorant, nous savons que nos algorithmes de tri sont asymptotiquement optimaux. En outre, nous pouvons employer le minorant du tri pour
trouver des minorants pour certains autres problèmes.
– Nombre de problèmes techniques surgissent quand on met en œuvre des algorithmes de tri. Les performances d’un programme de tri pour une situation donnée
peuvent dépendre d’un grand nombre de facteurs, par exemple la connaissance
que l’on a déjà concernant les clés et les données satellite, la hiérarchie des mémoires (caches et mémoire virtuelle) de l’ordinateur hôte ou l’environnement logiciel. Mieux vaut traiter ces problèmes au niveau algorithmique, plutôt que de
« bidouiller » le code.
Algorithmes de tri
Au chapitre 2, nous avons présenté deux algorithmes capables de trier n nombres
réels. Le tri par insertion s’exécute en Q(n2 ) dans le cas le plus défavorable. Toutefois,
comme ses boucles internes sont compactes, pour les entrées de taille réduite, c’est
c Dunod – La photocopie non autorisée est un délit
Tri et rang
119
un algorithme rapide de tri sur place. (Comme on l’a déjà vu, un algorithme trie sur
place quand il n’y a jamais plus d’un nombre constant d’éléments du tableau d’entrée
à être stocké hors du tableau.) Le tri par fusion a un temps d’exécution asymptotique
meilleur, Q(n lg n), mais la procédure F USION qu’il utilise n’opère pas sur place.
Dans cette partie, nous présenterons deux algorithmes supplémentaires, qui trient
des nombres réels arbitraires. Le tri par tas, étudié au chapitre 6, trie n nombres sur
place en O(n lg n). Il utilise une structure de données importante, appelée tas, qui
permet également d’implémenter une file de priorités.
Le tri rapide (ou quicksort) du chapitre 7, trie également n nombres sur place, mais
son temps d’exécution dans le pire des cas est Q(n2 ). En revanche, son temps d’exécution moyen est Q(n lg n), et il bat généralement le tri par tas en pratique. À l’instar
du tri par insertion, le tri rapide a un code compact, de sorte que le facteur constant
implicite de son temps d’exécution est petit. C’est un algorithme très répandu pour le
tri de grands tableaux.
Le tri par insertion, le tri par fusion, le tri par tas et le tri rapide sont tous des tris
par comparaison : ils déterminent l’ordre d’un tableau d’entrée en comparant les éléments. Le chapitre 8 commence par présenter le modèle de l’arbre de décision pour
étudier les limites de performance des tris par comparaison. A l’aide de ce modèle, on
peut trouver un minorant de V(n lg n) pour le temps d’exécution du cas le plus défavorable d’un tri par comparaison quelconque pour n entrées, ce qui montre que le tri
par tas et le tri par fusion sont des tris par comparaison asymptotiquement optimaux.
Le chapitre 8 montre ensuite qu’on peut améliorer ce minorant de V(n lg n) s’il
est possible de rassembler des informations sur l’ordre des valeurs d’entrée par
des moyens autres que la comparaison d’éléments. L’algorithme du tri par dénombrement, par exemple, suppose que les nombres en entrée sont dans l’ensemble
{1, 2, . . . , k}. En utilisant l’indexation de tableau comme outil pour déterminer
l’ordre relatif, le tri par dénombrement peut trier n nombres dans un temps Q(k + n).
Ainsi, quand k = O(n), le tri par dénombrement s’exécute dans un temps qui est une
fonction linéaire de la taille du tableau d’entrée. Un algorithme corollaire, le tri par
base (radix sort), permet d’étendre la portée du tri par dénombrement. Si l’on a n
entiers à trier, chaque entier s’écrivant sur d chiffres et chaque chiffre appartenant à
l’ensemble {1, 2, . . . , k}, alors le tri par base peut trier les nombres en Q(d(n + k)).
Lorsque d est une constante et k vaut O(n), le tri par base s’exécute en temps linéaire.
Un troisième algorithme, le tri par paquet (bucket sort), exige que l’on connaisse la
distribution probabiliste des nombres dans le tableau à trier. Il peut trier n nombres
réels uniformément distribués dans l’intervalle semi-ouvert [0, 1[ avec un temps O(n)
dans le cas moyen.
Rangs
Le ième rang d’un ensemble de n nombres est le ième plus petit nombre de l’ensemble. On peut bien sûr sélectionner le ième rang en triant l’entrée et en indexant le
ième élément de la sortie. Si l’on ne fait pas hypothèses préalables sur la distribution
120
Partie 2
en entrée, cette méthode s’exécute en V(n lg n), comme le montre le minorant établi
au chapitre 8.
Au chapitre 9, on montrera qu’on peut trouver le ième plus petit élément en O(n),
même quand les éléments sont des nombres réels arbitraires. Nous présenterons un
algorithme avec un pseudo-code compact qui s’exécute en Q(n2 ) dans le pire des
cas, et en temps linéaire en moyenne. Nous donnerons également un algorithme plus
compliqué qui s’exécute en O(n) dans le pire des cas.
Connaissances préliminaires
Bien que la majeure partie de cette partie ne fasse pas appel à des notions mathématiques difficiles, certaines sections ont besoin d’outils plus sophistiqués. En particulier, les analyses du cas moyen du tri rapide et du tri par paquet, ainsi que l’algorithme
du rang se servent des probabilités, qui sont revues à l’annexe C. L’analyse du cas
le plus défavorable de l’algorithme en temps linéaire pour le rang est plus complexe,
mathématiquement parlant, que les autres analyses de cas le plus défavorable vues
dans cette partie.
Chapitre 6
c Dunod – La photocopie non autorisée est un délit
Tri par tas
Dans ce chapitre, nous présentons un nouvel algorithme de tri. Comme le tri par
fusion, mais contrairement au tri par insertion, le temps d’exécution du tri par tas est
O(n lg n). Comme le tri par insertion, mais contrairement au tri par fusion, le tri par
tas trie sur place : à tout moment, jamais plus d’un nombre constant d’éléments de
tableau ne se trouvera stocké hors du tableau d’entrée. Le tri par tas rassemble donc
le meilleur des deux algorithmes de tri que nous avons déjà étudiés.
Le tri par tas introduit aussi une autre technique de conception des algorithmes : on
utilise une structure de données, appelée ici « tas » pour gérer les informations pendant l’exécution de l’algorithme. Non seulement le tas est une structure de données
utile pour le tri par tas, mais il permet aussi de construire une file de priorités efficace.
Cette structure de données réapparaîtra dans des algorithmes d’autres chapitres.
Le terme « tas » fut, au départ, inventé dans le contexte du tri par tas, mais il a
depuis pris le sens de « mémoire récupérable » (garbage-collected storage) comme
celle fournie par les langages Lisp et Java. Pour nous, le tas n’est pas une portion de
mémoire récupérable ; chaque fois qu’on parlera de tas dans ce livre il s’agira de la
structure définie dans ce chapitre.
6.1 TAS
La structure de tas (binaire) est un tableau qui peut être vu comme un arbre binaire
presque complet (voir Section B.5.3), comme le montre la figure 6.1. Chaque nœud
de l’arbre correspond à un élément du tableau qui contient la valeur du nœud. L’arbre
est complètement rempli à tous les niveaux, sauf éventuellement au niveau le plus bas,
6 • Tri par tas
122
qui est rempli en partant de la gauche et jusqu’à un certain point. Un tableau A représentant un tas est un objet ayant deux attributs : longueur[A], nombre d’éléments
du tableau, et taille[A], nombre d’éléments du tas rangés dans le tableau A. Autrement dit, bien que A[1 . . longueur[A]] puisse contenir des nombres valides, aucun
élément après A[taille[A]], où taille[A] longueur[A], n’est un élément du tas. La
racine de l’arbre est A[1], et étant donné l’indice i d’un nœud, les indices de son
parent PARENT(i), de son enfant de gauche G AUCHE(i) et de son enfant de droite
D ROITE(i) peuvent être facilement calculés :
PARENT(i)
retourner i/2
G AUCHE(i)
retourner 2i
D ROITE(i)
retourner 2i + 1
Sur la plupart des ordinateurs, la procédure G AUCHE peut calculer 2i en une seule
instruction, en décalant simplement d’une position vers la gauche la représentation
binaire de i. De même, la procédure D ROITE peut calculer rapidement 2i + 1 en
décalant d’une position vers la gauche la représentation binaire de i et en ajoutant un
1 comme bit de poids faible. La procédure PARENT peut calculer i/2 en décalant i
d’une position binaire vers la droite. Dans une bonne implémentation du tri par tas,
ces trois procédures sont souvent implémentées en tant que « macros », ou procédures
« en ligne ».
1
16
2
3
14
10
4
8
8
9
10
2
4
1
5
6
7
7
9
3
(a)
1
2
3
4
5
6
7
8
9
10
16 14 10 8
7
9
3
2
4
1
(b)
Figure 6.1 Un tas-max vu comme (a) un arbre binaire et (b) un tableau. Le nombre à l’intérieur
du cercle, à chaque nœud de l’arbre, est la valeur contenue dans ce nœud. Le nombre au-dessus
d’un nœud est l’indice correspondant dans le tableau. Au-dessus et au-dessous du tableau sont
des lignes matérialisant les relations parent-enfant ; les parents sont toujours à gauche de leurs
enfants. L’arbre a une hauteur de trois ; le nœud d’indice 4 (avec la valeur 8) a une hauteur de un.
6.1
Tas
123
Il existe deux sortes de tas binaires : les tax max et les tas min. Pour ces deux types
de tas, les valeurs des nœuds satisfont à une propriété de tas, dont la nature spécifique
dépend du type de tas. Dans un tas max, la propriété de tas max est que, pour chaque
nœud i autre que la racine,
A[PARENT(i)] A[i] ,
En d’autres termes, la valeur d’un nœud est au plus égale à celle du parent. Ainsi, le
plus grand élément d’un tas max est stocké dans la racine, et le sous-arbre issu d’un
certain nœud contient des valeurs qui ne sont pas plus grandes que celle du nœud
lui-même. Un tas min est organisé en sens inverse ; la propriété de tas min est que,
pour chaque nœud i autre que la racine,
A[PARENT(i)] A[i] .
Le plus petit élément d’un tas min est à la racine.
Pour l’algorithme du tri par tas, on utilise des tas max. Les tas min servent généralement dans les files de priorités, que nous verrons à la section 6.5. Nous préciserons
si nous prenons un tas max ou un tas min pour une application particulière ; pour les
propriétés qui s’appliquent indifféremment aux tax max et aux tas min, nous parlerons de « tas » tout court.
En considérant un tas comme un arbre, on définit la hauteur d’un nœud dans un
tas comme le nombre d’arcs sur le chemin simple le plus long reliant le nœud à une
feuille, et on définit la hauteur du tas comme étant la hauteur de sa racine. Comme
un tas de n éléments est basé sur un arbre binaire complet, sa hauteur est Q(lg n)
(voir Exercice 6.1.2). On verra que les opérations élémentaires sur les tas s’exécutent
dans un temps au plus proportionnel à la hauteur de l’arbre et prennent donc un
temps O(lg n). Le reste de ce chapitre présentera quelques procédures élémentaires et
montrera comment elles peuvent être utilisées dans un algorithme de tri et dans une
structure de données représentant une file de priorité.
– La procédure E NTASSER -M AX, qui s’exécute en O(lg n), est la clé de voûte de la
conservation de la propriété de tas max.
c Dunod – La photocopie non autorisée est un délit
– La procédure C ONSTRUIRE -TAS -M AX, qui s’exécute en un temps linéaire, produit un tas max à partir d’un tableau d’entrée non-ordonné.
– La procédure T RI -PAR -TAS, qui s’exécute en O(n lg n), trie un tableau sur place.
– Les procédures I NSÉRER -TAS -M AX, E XTRAIRE -M AX -TAS, AUGMENTER C LÉ -TAS et M AXIMUM -TAS, qui s’exécutent en O(lg n), permettent d’utiliser la
structure de données de tas pour gérer une file de priorité.
Exercices
6.1.1 Quels sont les nombres minimal et maximal d’éléments dans un tas de hauteur h ?
6.1.2 Montrer qu’un tas à n éléments a une hauteur lg n .
124
6 • Tri par tas
6.1.3 Montrer que pour un sous-arbre quelconque d’un tas max, la racine du sous-arbre
contient la plus grande valeur parmi celles de ce sous-arbre.
6.1.4 Ou pourrait se trouver le plus petit élément d’un tas max, en supposant que tous les
éléments sont distincts ?
6.1.5 Un tableau trié forme-t-il un tas min ?
6.1.6 La séquence 23, 17, 14, 6, 13, 10, 1, 5, 7, 12 forme-t-elle un tas max ?
6.1.7 Montrer que, si l’on emploie la représentation tableau pour un tas à n éléments, les
feuilles sont les nœuds indexés par n/2 + 1, n/2 + 2, . . . , n.
6.2 CONSERVATION DE LA STRUCTURE DE TAS
E NTASSER -M AX est un sous-programme important pour la manipulation des tas
max, qui prend en entrée un tableau A et un indice i. Quand E NTASSER -M AX est
appelée, on suppose que les arbres binaires enracinés en G AUCHE(i) et D ROITE(i)
sont des tas max, mais que A[i] peut être plus petit que ses enfants, violant ainsi la
propriété de tas max. Le rôle de E NTASSER -M AX est de faire « descendre » la valeur
de A[i] dans le tas max de manière que le sous-arbre enraciné en i devienne un tas
max.
E NTASSER -M AX(A, i)
1 l ← G AUCHE(i)
2 r ← D ROITE(i)
3 si l taille[A] et A[l] > A[i]
4
alors max ← l
5
sinon max ← i
6 si r taille[A] et A[r] > A[max]
7
alors max ← r
8 si max fi i
9
alors échanger A[i] ↔ A[max]
10
E NTASSER -M AX(A, max)
La figure 6.2 illustre l’action de E NTASSER -M AX. A chaque étape, on détermine
le plus grand des éléments A[i], A[G AUCHE(i)], et A[D ROITE(i)], et son indice est
rangé dans max. Si A[i] est le plus grand, alors le sous-arbre enraciné au nœud i
est un tas max et la procédure se termine. Sinon, c’est l’un des deux enfants qui
contient l’élément le plus grand, auquel cas A[i] est échangé avec A[max], ce qui
permet au nœud i et à ses enfants de satisfaire la propriété de tas max. Toutefois, le
nœud indexé par max contient maintenant la valeur initiale de A[i], et donc le sousarbre enraciné en max viole peut-être la propriété de tas max. E NTASSER -M AX doit
donc être appelée récursivement sur ce sous-arbre.
6.2
Conservation de la structure de tas
125
1
1
16
16
2
i
2
3
4
10
4
14
3
14
5
6
7
7
9
3
10
4
i
4
8
9
10
8
9
10
2
8
1
2
8
1
(a)
5
6
7
7
9
3
(b)
1
16
2
3
14
10
4
5
6
7
8
7
9
3
8
9
2
4
i
10
1
(c)
c Dunod – La photocopie non autorisée est un délit
Figure 6.2 L’action de E NTASSER -M AX(A, 2), où taille[A] = 10. (a) La configuration initiale du
tas, avec la valeur A[2] au nœud i = 2 viole la propriété de tas max puisqu’elle n’est pas supérieure à celle des deux enfants. La propriété de tas max est restaurée pour le nœud 2 en (b) via
échange de A[2] avec A[4], ce qui détruit la propriété de tas max pour le nœud 4. L’appel récursif E NTASSER -M AX(A, 4) prend maintenant i = 4. Après avoir échangé A[4] avec A[9], comme
illustré en (c), le nœud 4 est corrigé, et l’appel récursif E NTASSER -M AX(A, 9) n’engendre plus de
modifications de la structure de données.
Le temps d’exécution de E NTASSER -M AX sur un sous-arbre de taille n enraciné
en un nœud i donné est le temps Q(1) nécessaire pour corriger les relations entre les
éléments A[i], A[G AUCHE(i)], et A[D ROITE(i)], plus le temps d’exécuter E NTASSER M AX sur un sous-arbre enraciné sur l’un des enfants du nœud i. Les sous-arbres des
enfants ont chacun une taille au plus égale à 2n/3 (le pire des cas survient quand la
dernière rangée de l’arbre est remplie exactement à moitié), et le temps d’exécution
de la procédure E NTASSER -M AX peut donc être décrit par la récurrence
T(n) T(2n/3) + Q(1) .
La solution de cette récurrence, d’après le cas 2 du théorème général (théorème 4.1), est T(n) = O(lg n). On peut également caractériser le temps d’exécution
de E NTASSER -M AX sur un nœud de hauteur h par O(h).
Exercices
6.2.1 En prenant comme modèle la figure 6.2, illustrer l’action de E NTASSER -M AX(A, 3)
sur le tableau A = 27, 17, 3, 16, 13, 10, 1, 5, 7, 12, 4, 8, 9, 0.
126
6 • Tri par tas
6.2.2 En s’inspirant de la procédure E NTASSER -M AX, écrire du pseudo code pour la procédure E NTASSER -M IN (A, i) qui fait la même chose mais pour un tas min. Comparer le temps
d’exécution de E NTASSER -M IN et celui de E NTASSER -M AX ?
6.2.3 Quel est l’effet d’un appel E NTASSER -M AX (A, i) quand l’élément A[i] est plus grand
que ses enfants ?
6.2.4 Quel est l’effet d’un appel E NTASSER -M AX (A, i) pour i > taille[A]/2 ?
6.2.5 Le code de E NTASSER -M AX est assez efficace en termes de facteurs constants, sauf
peut-être pour l’appel récursif en ligne 10 qui risque d’entraîner certains compilateurs à générer du code inefficace. Écrire une procédure E NTASSER -M AX efficace qui utilise une structure de contrôle itérative (boucle) au lieu d’un appel récursif.
6.2.6 Montrer que le temps d’exécution dans le pire des cas de E NTASSER -M AX sur un
tas de taille n est V(lg n). (conseil : Pour un tas à n nœuds, donner des valeurs de nœuds
qui provoquent l’appel récursif de E NTASSER -M AX en chaque nœud d’un chemin reliant la
racine à une feuille.)
6.3 CONSTRUCTION D’UN TAS
On peut utiliser la procédure E NTASSER -M AX à l’envers pour convertir un tableau
A[1 . . n], avec n = length[A], en tas max. D’après l’exercice 6.1.7, les éléments du
sous-tableau A[( n/2 + 1) . . n] sont tous des feuilles de l’arbre, et donc chacun est
initialement un tas à 1 élément. La procédure C ONSTRUIRE -TAS -M AX parcourt les
autres nœuds de l’arbre et appelle E NTASSER -M AX pour chacun.
C ONSTRUIRE -TAS -M AX(A)
1 taille[A] ← longueur[A]
2 pour i ← longueur[A]/2 jusqu’à 1
3
faire E NTASSER -M AX(A, i)
La figure 6.3 montre un exemple de l’action de C ONSTRUIRE -TAS -M AX.
Pour prouver la conformité de C ONSTRUIRE -TAS -M AX, on utilise l’invariant de boucle suivant : Au début de chaque itération de la boucle pour des
lignes 2–3, chaque nœud i + 1, i + 2, . . . , n est la racine d’un tas max.
Il faut montrer que cet invariant est vrai avant la première itération, que chaque itération le conserve et qu’il fournit une propriété permettant de prouver la conformité
après la fin de l’exécution de la boucle.
Initialisation : Avant la première itération de la boucle, i = n/2 . Chaque nœud
n/2 + 1, n/2 + 2, . . . , n est une feuille et est donc la racine d’un tas max trivial.
6.3
Construction d’un tas
A 4
1
3
2 16 9 10 14 8
7
1
1
4
4
2
3
1
2
3
4
5
i 16
2
127
3
1
6
7
9
10
3
4
i
2
8
9
10
8
9
10
14
8
7
14
8
7
5
6
7
16
9
10
(a)
(b)
1
1
4
4
2
3
1
3
i
i
3
1
10
4
5
6
7
4
5
6
7
14
16
9
10
14
16
9
3
8
9
10
8
9
10
2
8
7
2
8
7
i
c Dunod – La photocopie non autorisée est un délit
2
(c)
(d)
1
1
4
16
2
3
2
3
16
10
14
10
4
5
6
7
4
5
6
7
14
7
9
3
8
7
9
3
8
9
10
2
8
1
(e)
8
9
10
2
4
1
(f)
Figure 6.3 Fonctionnement de C ONSTRUIRE -TAS -M AX, montrant la structure de données avant
l’appel à E NTASSER -M AX en ligne 3 de C ONSTRUIRE -TAS -M AX. (a) Un tableau de 10 éléments
en entrée, avec l’arbre binaire qu’il représente. La figure montre que l’indice de boucle i pointe
vers le nœud 5 avant l’appel E NTASSER -M AX(A, i). (b) La structure de données résultante. L’indice
de boucle i de l’itération suivante pointe vers le nœud 4. (c)–(e) Les itérations suivantes de la
boucle pour de C ONSTRUIRE -TAS -M AX. Observez que, chaque fois qu’il y a appel de E NTASSER M AX sur un nœud, les deux sous-arbres de ce nœud sont des tas max. (f) Le tas max après que
C ONSTRUIRE -TAS -M AX a fini.
6 • Tri par tas
128
Conservation : Pour voir que chaque itération conserve l’invariant, observez que
les enfants du nœud i ont des numéros supérieurs à i. D’après l’invariant, ce
sont donc tous les deux des racines de tas max. Telle est précisément la condition exigée pour que l’appel E NTASSER -M AX(A, i) fasse du nœud i une racine de
tas max. En outre, l’appel E NTASSER -M AX préserve la propriété que les noeuds
i + 1, i + 2, . . . , n sont tous des racines de tas max. La décrémentation de i dans
la partie actualisation de la boucle pour a pour effet de rétablir l’invariant pour
l’itération suivante.
Terminaison : À la fin, i = 0. D’après l’invariant, chaque nœud 1, 2, . . . , n est la
racine d’un tas max. En particulier, le nœud 1.
On peut calculer un majorant simple pour le temps d’exécution de C ONSTRUIRE TAS -M AX de la manière suivante. Chaque appel à E NTASSER -M AX coûte O(lg n), et
il existe O(n) appels de ce type. Le temps d’exécution est donc O(n lg n). Ce majorant,
quoique correct, n’est pas asymptotiquement serré.
On peut trouver un majorant plus fin en observant que le temps d’exécution de
E NTASSER -M AX sur un nœud varie avec la hauteur du nœud dans l’arbre, et que les
hauteurs de la plupart des nœuds sont réduites. Notre analyse plus fine s’appuie sur
les propriétés d’un tas à n éléments : sa hauteur est lg n (voir exercice 6.1.2) et le
nombre de nœuds ayant la hauteur h est au plus n/2h+1 (voir exercice 6.3.3).
Le temps requis par E NTASSER -M AX quand elle est appelée sur un nœud de hauteur h étant O(h), on peut donc exprimer le coût total de C ONSTRUIRE -TAS -M AX
ainsi


lg n lg n
h
n
O(h) = O n
.
h+1
2
2h
h=0
h=0
La dernière sommation peut être évaluée en substituant x = 1/2 dans la formule (A.8),
ce qui donne
∞
1/2
h
=
=2.
2h (1 − 1/2)2
h=0
Donc, le temps d’exécution de C ONSTRUIRE -TAS -M AX peut être borné par


lg n
∞
h
h
 = O n
O n
h
2
2h
h=0
h=0
= O(n) .
On peut donc construire un tas max, à partir d’un tableau non-ordonné, en un temps
linéaire.
On peut construire un tas min via la procédure C ONSTRUIRE -TAS -M IN qui est
identique à C ONSTRUIRE -TAS -M AX, sauf que l’appel à E NTASSER -M AX en ligne 3
est remplacé par un appel à E NTASSER -M IN (voir exercice 6.2.2). C ONSTRUIRE TAS -M IN produit un tas min en temps linéaire à partir d’un tableau non ordonné.
6.4
Algorithme du tri par tas
129
Exercices
6.3.1 En prenant modèle sur la figure 6.3, illustrer l’action de C ONSTRUIRE -TAS -M AX sur
le tableau A = 5, 3, 17, 10, 84, 19, 6, 22, 9.
6.3.2 Pourquoi fait-on décroître l’indice de boucle i de la ligne 2 de C ONSTRUIRE -TAS M AX depuis longueur[A]/2 jusqu’à 1, au lieu de le faire croître de 1 à longueur[A]/2 ?
6.3.3 Montrer qu’il existe au plus n/2h+1 nœuds de hauteur h dans un tas quelconque à
n éléments.
6.4 ALGORITHME DU TRI PAR TAS
L’algorithme du tri par tas commence par utiliser C ONSTRUIRE -TAS -M AX pour
construire un tas max à partir du tableau A[1 . . n], où n = longueur[A]. Comme
l’élément maximal du tableau est stocké à la racine A[1], on peut le placer dans sa
position finale correcte en l’échangeant avec A[n]. Si l’on « ôte » à présent le nœud n
du tas (en décrémentant taille[A]), on observe que A[1 . . (n − 1)] peut facilement être
transformé en tas max. Les enfants de la racine restent des tas max, mais la nouvelle
racine risque d’enfreindre la propriété de tas max. Pour restaurer la propriété de tas
max, il suffit toutefois d’appeler une seule fois E NTASSER -M AX(A, 1) qui laisse un
tas max dans A[1 . . (n − 1)]. L’algorithme du tri par tas répète alors ce processus pour
le tas max de taille n − 1 jusqu’à arriver à un tas de taille 2. (Voir exercice 6.4.2 pour
plus de précisions sur l’invariant de boucle.)
c Dunod – La photocopie non autorisée est un délit
T RI -PAR -TAS(A)
1 C ONSTRUIRE -TAS -M AX(A)
2 pour i ← longueur[A] jusqu’à 2
3
faire échanger A[1] ↔ A[i]
4
taille[A] ← taille[A] − 1
5
E NTASSER -M AX(A, 1)
La figure 6.4 montre l’action du tri par tas juste après la construction du tas max.
Chaque tas max est montré au début d’une itération de la boucle pour des lignes 2–5.
La procédure T RI -PAR -TAS prend un temps O(n lg n), puisque l’appel à
C ONSTRUIRE -TAS -M AX prend un temps O(n) et que chacun des n − 1 appels
à E NTASSER -M AX prend un temps O(lg n).
Exercices
6.4.1 En s’aidant de la figure 6.4, illustrer l’action de T RI -PAR -TAS sur le tableau A = 5,
13, 2, 25, 7, 17, 20, 8, 4.
6 • Tri par tas
130
16
14
14
10
8
2
7
4
9
8
3
2
3
16 i
4
7
1
2
9
8
7
3
1
7
2
3
4
10
2
14
1
4
i 9
16
3
1
10
2
14
8 i
(e)
(f)
4
3
2
3
i 7
8
2
9
1
i 4
16
10
7
14
(g)
8
1
9
16
3 i
4
10
(h)
7
14
9
16
(d)
2
3
i
14 16
(c)
7
14
1
9
9
(b)
i 10 14 16
10
7
8
(a)
8
1
10
4
1
4
10
8
9
16
(i)
1
i 2
3
A
4
7
8
1
2 3
4 7
8 9 10 14 16
9
10 14 16
(j)
(k)
Figure 6.4 L’action de T RI -PAR -TAS. (a) La structure de tas max juste après sa construction par
C ONSTRUIRE -TAS -M AX. (b)–(j) Le tas max juste après chaque appel E NTASSER -M AX en ligne 5.
La valeur de i à ce moment est montrée. Seul les nœuds légèrement ombrés restent dans le tas.
(k) Le résultat du tri sur le tableau A.
6.4.2 Prouver la conformité de T RI -PAR -TAS à l’aide de l’invariant de boucle suivant :
Au début de chaque itération de la boucle pour des lignes 2–5, le sous-tableau A[1 . . i] est
un tas max contenant les i plus petits éléments de A[1 . . n], et le sous-tableau A[i + 1 . . n]
contient les n − i plus grands éléments de A[1 . . n], triés.
6.4.3 Quel est le temps d’exécution du tri par tas sur un tableau A de longueur n déjà trié en
ordre croissant ? Et en ordre décroissant ?
6.4.4 Montrer que le temps d’exécution du tri par tas dans le cas le plus défavorable est
V(n lg n).
6.4.5 Montrer que, quand tous les éléments sont distincts, le temps d’exécution optimal du
tri par tas est V(n lg n).
6.5
Files de priorité
131
6.5 FILES DE PRIORITÉ
Le tri par tas est un excellent algorithme, mais une bonne implémentation du tri rapide
(quicksort), qui sera vu au chapitre 7, est généralement plus performante en pratique.
Néanmoins, la structure de données tas offre intrinsèquement de gros avantages. Dans
cette section, nous allons présenter l’une des applications les plus répandues du tas,
à savoir la gestion d’une file de priorité efficace. Comme c’est le cas avec les tas,
il existe deux sortes de files de priorité : les files max et les files min. Nous nous
concentrerons ici sur la mise en œuvre des files de priorité max, lesquelles s’appuient
sur des tas max ; l’exercice 6.5.3 vous demandera d’écrire les procédures concernant
les files de priorité min.
Une file de priorité est une structure de données qui permet de gérer un ensemble S
d’éléments, dont chacun a une valeur associée baptisée clé. Une file de priorité max
reconnaît les opérations suivantes.
I NSÉRER(S, x) insère l’élément x dans l’ensemble S. Cette opération pourrait s’écrire
sous la forme S ← S ∪ {x}.
M AXIMUM(S) retourne l’élément de S ayant la clé maximale.
E XTRAIRE -M AX(S) supprime et retourne l’élément de S qui a la clé maximale.
AUGMENTER -C LÉ(S, x, k) accroît la valeur de la clé de l’élément x pour lui donner
la nouvelle valeur k, qui est censée être supérieure ou égale à la valeur courante de
la clé de x.
c Dunod – La photocopie non autorisée est un délit
L’une des applications des files de priorité max est de planifier les tâches sur un
ordinateur. La file max gère les travaux en attente, avec leurs priorité relatives. Quand
une tâche est terminée ou interrompue, l’ordinateur exécute la tâche de plus forte
priorité, sélectionnée via E XTRAIRE -M AX parmi les travaux en attente. La procédure
I NSÉRER permet d’ajouter à tout moment une nouvelle tâche à la file.
Une file de priorité min reconnaît les opérations I NSÉRER, M INIMUM, E XTRAIRE M IN et D IMINUER -C LÉ. Une file de priorité min peut servir à gérer un simulateur
piloté par événement. Les éléments de la file sont des événements à simuler, chacun
étant affecté d’un temps d’occurrence qui lui sert de clé. Les événements doivent être
simulés dans l’ordre des temps d’occurrence, vu que la simulation d’un événement
risque d’entraîner la simulation ultérieure d’autres événements. Le programme de
simulation emploie E XTRAIRE -M IN à chaque étape pour choisir le prochain événement à simuler. À mesure que sont produits de nouveaux événements, ils sont insérés
dans la file de priorité min via I NSÉRER. Nous verrons d’autres utilisations pour les
files de priorité min, notamment l’opération D IMINUER -C LÉ, aux chapitres 23 et 24.
Il n’y a rien d’étonnant à ce qu’un tas permette de gérer une file de priorité. Dans
une application donnée, par exemple dans un planificateur de tâches ou dans un simulateur piloté par événement, les éléments de la file de priorité correspondent à
des objets de l’application. Il est souvent nécessaire de déterminer quel est l’objet de
l’application qui correspond à un certain élément de la file de priorité, et vice-versa.
132
6 • Tri par tas
Quand on utilise un tas pour gérer une file de priorité, on a donc souvent besoin de
stocker dans chaque élément du tas un repère référençant l’objet applicatif correspondant. La nature exacte du repère (pointeur ? entier ? etc.) dépend de l’application.
De même, on a besoin de stocker dans chaque objet applicatif un repère référençant
l’élément de tas correspondant. Ici, le repère est généralement un indice de tableau.
Comme les éléments du tas changent d’emplacement dans le tableau lors des opérations de manipulation du tas, une implémentation concrète doit, en cas de relogement
d’un élément du tas, actualiser en parallèle l’indice de tableau dans l’objet applicatif
correspondant. Comme les détails de l’accès aux objets de l’application dépendent
grandement de l’application et de son implémentation, nous n’en parlerons plus, si
ce n’est pour noter que, dans la pratique, il importe de gérer correctement ces repères.
Nous allons maintenant voir comment implémenter les opérations associées à une
file de priorité max. La procédure M AXIMUM -TAS implémente l’opération M AXI MUM en un temps Q(1).
M AXIMUM -TAS(A)
1 retourner A[1]
La procédure E XTRAIRE -M AX -TAS implémente l’opération E XTRAIRE -M AX.
Elle ressemble au corps de la boucle pour (lignes 3-5) de la procédure T RI -PAR TAS.
E XTRAIRE -M AX -TAS(A)
1 si taille[A] < 1
2
alors erreur « limite inférieure dépassée »
3 max ← A[1]
4 A[1] ← A[taille[A]]
5 taille[A] ← taille[A] − 1
6 E NTASSER -M AX(A, 1)
7 retourner max
Le temps d’exécution de E XTRAIRE -M AX -TAS est O(lg n), car elle n’effectue qu’un
volume constant de travail en plus du temps O(lg n) de E NTASSER -M AX.
La procédure AUGMENTER -C LÉ -TAS implémente l’opération AUGMENTER C LÉ. L’élément de la file de priorité dont il faut accroître la clé est identifié par
un indice i pointant vers le tableau. La procédure commence par modifier la clé de
l’élément A[i] pour lui donner sa nouvelle valeur. Accroître la clé de A[i] risque d’enfreindre la propriété de tas max ; la procédure, d’une manière qui rappelle la boucle
d’insertion (lignes 5–7) de T RI -I NSERTION vue à la section 2.1, parcourt donc un
chemin reliant ce nœud à la racine, afin de trouver une place idoine pour la clé qui
vient d’être augmentée. Tout au long du parcours, elle compare un élément à son parent ; elle permute les clés puis continue si la clé de l’élément est plus grande, et elle
s’arrête si la clé de l’élément est plus petite, vu que la propriété de tas max est alors
satisfaite. (Voir exercice 6.5.5 pour une description précise de l’invariant de boucle.)
6.5
Files de priorité
133
AUGMENTER -C LÉ -TAS(A, i, clé)
1 si clé < A[i]
2
alors erreur « nouvelle clé plus petite que clé actuelle »
3 A[i] ← clé
4 tant que i > 1 et A[PARENT(i)] < A[i]
5
faire permuter A[i] ↔ A[PARENT(i)]
6
i ← PARENT(i)
La figure 6.5 montre un exemple d’opération AUGMENTER -C LÉ -TAS. Le temps
d’exécution de AUGMENTER -C LÉ -TAS sur un tas à n éléments est O(lg n), car le
chemin reliant le nœud, modifié en ligne 3, à la racine a la longueur O(lg n).
16
16
14
10
8
7
9
14
3
8
7
9
3
i
i
2
10
4
1
15
2
1
(b)
(a)
16
16
i
14
15
10
10
i
15
2
7
8
9
1
14
2
(c)
c Dunod – La photocopie non autorisée est un délit
3
7
8
9
3
1
(d)
Figure 6.5 Fonctionnement de AUGMENTER -C LÉ -TAS. (a) Le tas max de la figure 6.4(a) avec
un nœud dont l’indice est i en gris foncé. (b) Ce nœud voit sa clé passer à 15. (c) Après une
itération de la boucle tant que des lignes 4–6, le nœud et son parent s’échangent leurs clés et
l’indice i remonte pour devenir celui du parent. (d) Le tas max après une itération supplémentaire
de la boucle tant que. À ce stade, A[PARENT(i)] A[i]. La propriété de tas max étant maintenant
vérifiée, la procédure se termine.
La procédure I NSÉRER -TAS -M AX implémente l’opération I NSÉRER. Elle prend
en entrée la clé du nouvel élément à insérer dans le tas max A. La procédure commence par étendre le tas max en ajoutant à l’arbre une nouvelle feuille dont la clé
est −∞. Elle appelle ensuite AUGMENTER -C LÉ -TAS pour affecter à la clé du nouveau nœud la bonne valeur et conserver la propriété de tas max.
134
6 • Tri par tas
I NSÉRER -TAS -M AX(A, clé)
1 taille[A] ← taille[A] + 1
2 A[taille[A]] ← −∞
3 AUGMENTER -C LÉ -TAS(A, taille[A], clé)
Le temps d’exécution de I NSÉRER -TAS -M AX sur un tas à n éléments est O(lg n).
En résumé, un tas permet de faire toutes les opérations de file de priorité sur un
ensemble de taille n en un temps O(lg n).
Exercices
6.5.1 Illustrer le fonctionnement de E XTRAIRE -M AX -TAS sur le tas A = 15, 13, 9, 5, 12, 8,
7, 4, 0, 6, 2, 1.
6.5.2 Illustrer le fonctionnement de I NSÉRER -TAS -M AX (A, 10) sur le tas A = 15, 13, 9, 5,
12, 8, 7, 4, 0, 6, 2, 1. Utiliser le tas de la figure 6.5 comme modèle pour l’appel AUGMENTER C LÉ -TAS.
6.5.3 Écrire le pseudo code des procédures M INIMUM -TAS, E XTRAIRE -M IN -TAS,
D IMINUER -C LÉ -TAS et I NSÉRER -TAS -M IN qui implémentent une file de priorité min
basée sur un tas min.
6.5.4 Pourquoi faut-il régler la clé du nœud inséré sur la valeur −∞, en ligne 2 de I NSÉRER TAS -M AX, puisque l’étape immédiatement suivante sera de donner à la clé la valeur souhaitée ?
6.5.5 Prouver la conformité de AUGMENTER -C LÉ -TAS à l’aide de l’invariant de boucle
suivant :
Au début de chaque itération de la boucle tant que des lignes 4–6, le tableau A[1 . . taille[A]]
satisfait à la propriété de tas max, à une infraction potentielle près : A[i] risque d’être plus
grand que A[PARENT(i)].
6.5.6 Montrer comment implémenter une file FIFO (first-in first-out) avec une file de priorité. Montrer comment implémenter une pile avec une file de priorité. (Files et piles sont
définies à la section 10.1.)
6.5.7 L’opération S UPPRIMER -TAS (A, i) supprime dans le tas A l’élément placé dans le
nœud i. Donner une implémentation de S UPPRIMER -TAS qui tourne en un temps O(lg n)
pour un tas max à n éléments.
6.5.8 Donner un algorithme à temps O(n lg k) qui fusionne k listes triées pour produire une
liste triée unique, n étant ici le nombre total d’éléments toutes listes confondues. (Conseil :
Utiliser un tas min pour la fusion multiple.)
Problèmes
135
PROBLÈMES
6.1. Construire un tas via insertion
La procédure C ONSTRUIRE -TAS -M AX de la section 6.3 peut être implémentée via
un usage répété de I NSÉRER -TAS -M AX pour insérer les éléments dans le tas. On
considère l’implémentation suivante :
C ONSTRUIRE -TAS -M AX (A)
1 taille[A] ← 1
2 pour i ← 2 à longueur[A]
3
faire I NSÉRER -TAS -M AX(A, A[i])
a. Les procédures C ONSTRUIRE -TAS -M AX et C ONSTRUIRE -TAS -M AX construisentelles toujours le même tas quand elles sont exécutées sur le même tableau
d’entrée ? Le démontrer, ou donner un contre-exemple.
b. Montrer que, dans le pire des cas, C ONSTRUIRE -TAS -M AX nécessite un temps
Q(n lg n) pour construire un tas de n éléments.
c Dunod – La photocopie non autorisée est un délit
6.2. Analyse de tas d-aire
Un tas d-aire ressemble a un tas binaire, à ceci près qu’un nœud qui n’est pas une
feuille a d enfants au lieu de 2 (à une exception potentielle près).
a. Comment pourrait-on représenter un tas d-aire dans un tableau ?
b. Quelle est la hauteur d’un tas d-aire à n éléments en fonction de n et d ?
c. Donner une implémentation efficace de E XTRAIRE -M AX pour un tas max d-aire.
Analyser son temps d’exécution en fonction de d et n.
d. Donner une implémentation efficace de I NSÉRER pour un tas max d-aire. Analyser
son temps d’exécution en fonction de d et n.
e. Donner une implémentation efficace de AUGMENTER -C LÉ(A, i, k), qui fait
A[i] ← max(A[i], k) puis qui met à jour correctement la structure de tas max
d-aire. Analyser son temps d’exécution en fonction de d et n.
6.3. Tableaux de Young
Un tableau de Young m × n est une matrice m × n telle que les éléments de chaque
ligne sont triés dans le sens gauche-droite et les éléments de chaque colonne sont
triés dans le sens haut-bas. Un tableau de Young peut contenir des valeurs ∞, qui
sont considérées comme des éléments non existants. Un tableau de Young permet
donc de stocker r mn nombres finis.
a. Dessiner un tableau de Young 4 × 4 qui contienne les éléments {9, 16, 3, 2, 4, 8, 5,
14, 12}.
6 • Tri par tas
136
b. Prouver qu’un tableau de Young m × n Y est vide si Y[1, 1] = ∞. Prouver que Y
est plein (contient mn éléments) si Y[m, n] < ∞.
c. Donner un algorithme qui implémente E XTRAIRE -M IN sur un tableau de Young
m × n non vide et qui tourne en un temps O(m + n). L’algorithme doit utiliser une
sous-routine récursive qui résout un problème m × n en résolvant récursivement
soit un sous-problème (m − 1) × n, soit un sous-problème m × (n − 1). (conseil :
Penser à E NTASSER -M AX.) Soit T(p), où p = m+n, le temps d’exécution maximal
de E XTRAIRE -M IN sur un tableau de Young m×n quelconque. Donner et résoudre
une récurrence pour T(p) qui produise le majorant temporel O(m + n).
d. Montrer comment insérer un nouvel élément dans un tableau de Young m × n non
plein en un temps O(m + n).
e. En n’utilisant pas d’autre méthode de tri comme sous-routine, montrer comment
utiliser un tableau de Young n × n pour trier n2 nombres en un temps O(n3 ).
f. Donner un algorithme à temps O(m + n) qui détermine si un nombre donné appartient à un tableau de Young m × n donné.
NOTES
L’algorithme du tri par tas fut inventé par Williams [316], qui a expliqué aussi comment
implémenter une file de priorité à l’aide d’un tas. La procédure C ONSTRUIRE -TAS -M AX a
été suggérée par Floyd [90].
Nous reverrons les tas min pour implémenter des files de priorité min aux chapitres 16,
23 et 24. Nous donnerons aussi une implémentation avec des bornes temporelles améliorées
pour certaines opérations aux chapitres 19 et 20.
Il est possible de trouver des implémentations plus rapides des files de priorité pour les
données de type entier. Une structure de données inventée par van Emde Boas [301] permet
de faire les opérations M INIMUM, M AXIMUM, I NSÉRER, S UPPRIMER, R ECHERCHER,
E XTRAIRE -M IN, E XTRAIRE -M AX, P RÉDÉCESSEUR, et S UCCESSEUR avec un temps
O(lg lg C) dans le cas le plus défavorable, avec la restriction que l’ensemble de clés doit
être l’ensemble {1, 2, . . . , C}. Si les données sont des entiers à b bits et que la mémoire de
l’ordinateur se compose de mots adressables de b bits, Fredman et Willard [99] ont montré
comment√
implémenter M INIMUM en un temps O(1) et
√I NSÉRER et E XTRAIRE -M IN en un
temps O( lg n). Thorup [299] a amélioré la borne O( lg n), en la ramenant à O((lg lg n)2 ).
Cette borne utilise une quantité d’espace non bornée en n, mais on peut l’implémenter en
espace linéaire à l’aide d’un hachage randomisé.
Un cas particulier important des files de priorité est celui où la séquence des opérations
E XTRAIRE -M IN est monotone, c’est-à-dire quand les valeurs produites par les appels successifs à E XTRAIRE -M IN augmentent avec le temps. Ce cas se produit dans plusieurs applications importantes, dont l’algorithme de Dijkstra pour les plus courts chemins à origine
unique (que nous verrons au chapitre 24) et la simulation d’événements discrets. Pour l’algorithme de Dijkstra, il importe particulièrement que l’opération D IMINUER -C LÉ soit mise en
œuvre de manière efficace. Pour le cas monotone, si les données sont des entiers appartenant à
Notes
137
c Dunod – La photocopie non autorisée est un délit
l’intervalle 1, 2, . . . , C, alors Ahuja, Melhorn, Orlin et Tarjan [8] expliquent comment implémenter E XTRAIRE -M IN et I NSÉRER en un temps amorti O(lg C) (voir chapitre 17 pour plus
de détails sur l’analyse amortie) et D IMINUER -C LÉ en un temps O(1), en utilisant une√structure de données dite tas base (radix heap). La borne O(lg C) peut être ramenée à O( lg C)
en utilisant une combinaison de tas de Fibonacci (voir chapitre 20) et de tas base. La borne a
été encore améliorée, passant à un temps attendu O(lg1/3+´ C), par Cherkassky, Goldberg et
Silverstein [58], qui combinent la structure de paquet multiniveau (multilevel bucketing) de
Denardo et Fox [72] et le tas de Thorup susmentionné. Raman [256] a encore amélioré ces
résultats, obtenant une borne de O(min(lg1/4+´ C, lg1/3+´ n)) pour tout ´ > 0 fixé. On trouvera
des présentations détaillées de ces résultats dans des articles de Raman [256] et Thorup [299].
Chapitre 7
Tri rapide
c Dunod – La photocopie non autorisée est un délit
Le tri rapide (quicksort) est un algorithme de tri dont le temps d’exécution, dans le
cas le plus défavorable, est Q(n2 ) sur un tableau de n nombres. En dépit de ce temps
d’exécution lent dans le cas le plus défavorable, le tri rapide est souvent le meilleur
choix en pratique, à cause de son efficacité remarquable en moyenne : son temps
d’exécution attendu est Q(n lg n) et les facteurs constants cachés dans la notation
Q(n lg n) sont réduits. Il a également l’avantage de trier sur place (voir page 15) et il
fonctionne bien même dans des environnements de mémoire virtuelle.
La section 7.1 décrit l’algorithme, ainsi qu’un sous-programme de partitionnement
important utilisé par le tri rapide. À cause de la complexité du comportement du tri
rapide, nous commencerons par une étude intuitive de ses performances dans la section 7.2 et renverrons l’analyse précise à la fin du chapitre. La section 7.3 présente une
version du tri rapide qui s’appuie sur de l’échantillonnage aléatoire. Cet algorithme a
un bon temps d’exécution pour le cas moyen et il n’y a aucune entrée spécifique qui
entraîne le comportement le plus défavorable. L’algorithme randomisé est analysé à
la section 7.4, où l’on montre qu’il s’exécute en un temps Q(n2 ) dans le cas le plus
défavorable et en un temps O(n lg n) en moyenne.
7.1 DESCRIPTION DU TRI RAPIDE
Le tri rapide, comme le tri par fusion est fondé sur le paradigme diviser-pour-régner,
présenté à la section 2.3.1. Voici les trois étapes du processus diviser-pour-régner
employé pour trier un sous-tableau typique A[p . . r].
140
7 • Tri rapide
Diviser : Le tableau A[p . . r] est partitionné (réarrangé) en deux sous-tableaux
(éventuellement vides) A[p . . q − 1] et A[q + 1 . . r] tels que chaque élément de
A[p . . q − 1] soit inférieur ou égal à A[q] qui, lui-même, est inférieur ou égal
à chaque élément de A[q + 1 . . r]. L’indice q est calculé dans le cadre de cette
procédure de partitionnement.
Régner : Les deux sous-tableaux A[p . . q − 1] et A[q + 1 . . r] sont triés par des appels
récursifs au tri rapide.
Combiner : Comme les sous-tableaux sont triés sur place, aucun travail n’est nécessaire pour les recombiner : le tableau A[p . . r] tout entier est maintenant trié.
La procédure suivante implémente le tri rapide.
T RI -R APIDE(A, p, r)
1 si p < r
2
alors q ← PARTITION(A, p, r)
3
T RI -R APIDE(A, p, q − 1)
4
T RI -R APIDE(A, q + 1, r)
Pour trier un tableau A entier, l’appel initial est T RI -R APIDE(A, 1, longueur[A]).
a) Partitionner le tableau
Le point principal de l’algorithme est la procédure PARTITION, qui réarrange le soustableau A[p . . r] sur place.
PARTITION(A, p, r)
1 x ← A[r]
2 i←p−1
3 pour j ← p à r − 1
4
faire si A[j] x
5
alors i ← i + 1
6
permuter A[i] ↔ A[j]
7 permuter A[i + 1] ↔ A[r]
8 retourner i + 1
La figure 7.1 montre le fonctionnement de PARTITION sur un tableau à 8
éléments. PARTITION sélectionne toujours un élément x = A[r] comme élément pivot autour duquel se fera le partitionnement du sous-tableau A[p . . r].
La procédure continue et le tableau est partitionné en quatre régions (éventuellement vides). Au début de chaque itération de la boucle pour des lignes
3–6, chaque région satisfait à certaines propriétés qui définissent un invariant
de boucle : Au début de chaque itération de la boucle des lignes 3–6, pour tout
indice k,
1) Si p k i, alors A[k] x.
c Dunod – La photocopie non autorisée est un délit
7.1
Description du tri rapide
141
(a)
i p,j
2 8
7
1
3
5
6
r
4
(b)
p,i j
2 8
7
1
3
5
6
r
4
(c)
p,i
2 8
j
7
1
3
5
6
r
4
(d)
p,i
2 8
7
j
1
3
5
6
r
4
(e)
p
2
i
1
7
8
j
3
5
6
r
4
(f)
p
2
1
i
3
8
7
j
5
6
r
4
(g)
p
2
1
i
3
8
7
5
j
6
r
4
(h)
p
2
1
i
3
8
7
5
6
r
4
(i)
p
2
1
i
3
4
7
5
6
r
8
Figure 7.1 Fonctionnement de PARTITION sur un exemple. Les éléments en gris clair sont tous
dans la première partition, avec des valeurs pas plus grandes que x. les éléments en gris foncé
sont dans la seconde partition, avec des valeurs supérieures à x. Les éléments non en gris n’ont
pas encore été placés dans l’une des deux premières partitions et l’élément blanc final est le pivot.
(a) Le tableau initial et les configurations de variable initiales. Aucun des éléments n’a été placé
dans l’une quelconque des deux premières partitions. (b) La valeur 2 est « permutée avec ellemême » et placée dans la partition des petites valeurs. (c)–(d) Les valeurs 8 et 7 sont ajoutées à la
partition des grandes valeurs. (e) Les valeurs 1 et 8 sont permutées et la petite partition grossit.
(f) Les valeurs 3 et 7 sont échangées et la petite partition grossit. (g)–(h) La grande partition
grossit pour accueillir 5 et 6 et la boucle se termine. (i) Sur les lignes 7–8, l’élément pivot est
permuté de façon à aller entre les deux partitions.
2) Si i + 1 k j − 1, alors A[k] > x.
3) Si k = r, alors A[k] = x.
La figure 7.2 résume cette structure. Les indices entre j et r − 1 n’entrent dans
aucun des trois cas et les valeurs des éléments correspondants n’ont pas de relation
particulière avec le pivot x.
Il faut montrer que cet invariant de boucle est vrai avant la première itération, que
chaque itération de la boucle conserve l’invariant et que celui-ci fournit une propriété
qui prouve la conformité quand la boucle se termine.
7 • Tri rapide
142
Initialisation : Avant la première itération, i = p − 1 et j = p. Il n’y a pas de valeurs
entre p et i, ni de valeurs entre i + 1 et j − 1, de sorte que les deux premières
conditions de l’invariant de boucle sont satisfaites de manière triviale. L’affection
en ligne 1 satisfait à la troisième condition.
Conservation : Comme le montre la figure 7.3, il y a deux cas à considérer, selon le
résultat du test en ligne 4. La figure 7.3(a) montre ce qui se passe quand A[j] > x ;
l’unique action faite dans la boucle est d’incrémenter j. Une fois j incrémenté, la
condition 2 est vraie pour A[j − 1] et toutes les autres éléments restent inchangés. La figure 7.3(b) montre ce qui se passe quand A[j] x ; i est incrémenté,
A[i] et A[j] sont échangés, puis j est incrémenté. Compte tenu de la permutation,
on a maintenant A[i] x et la condition 1 est respectée. De même, on a aussi
A[j − 1] > x, car l’élément qui a été permuté avec A[j − 1] est, d’après l’invariant
de boucle, plus grand que x.
Terminaison : À la fin, j = r. Par conséquent, chaque élément du tableau est dans
l’un des trois ensembles décrits par l’invariant et l’on a partitionné les valeurs
du tableau en trois ensembles : les valeurs inférieures ou égales à x, les valeurs
supérieures à x et un singleton contenant x.
p
i
≤x
j
>x
r
x
arbitraires
Figure 7.2 Les quatre régions gérées par la procédure PARTITION sur un sous-tableau A[p . . r].
Les valeurs de A[p . . i] sont toutes inférieures ou égales à x, les valeurs de A[i + 1 . . j − 1] sont
toutes supérieures à x et A[r] = x. Les éléments de A[j . . r − 1] peuvent prendre n’importe quelles
valeurs.
Les deux dernières lignes de PARTITION transfèrent le pivot vers son nouvel emplacement au milieu du tableau, en l’échangeant avec l’élément le plus à gauche qui
soit supérieur à x. Le résultat de PARTITION respecte maintenant les spécifications de
l’étape diviser.
Le temps d’exécution de PARTITION pour le sous-tableau A[p . . r] est Q(n), où
n = r − p + 1 (voir exercice 7.1.3).
Exercices
7.1.1 En s’inspirant de la figure 7.1, illustrer l’action de PARTITION sur le tableau A = 13,
19, 9, 5, 12, 8, 7, 4, 11, 2, 6, 21.
7.1.2 Quelle est la valeur de q retournée par PARTITION quand tous les éléments du tableau
A[p . . r] ont la même valeur ? Modifier PARTITION pour que q = (p + r)/2 quand tous les
éléments du tableau A[p . . r] ont la même valeur.
7.2
Performances du tri rapide
p
143
i
j
>x
(a)
≤x
p
>x
i
j
≤x
p
j
≤x
(b)
≤x
r
x
>x
i
≤x
r
x
>x
i
p
r
x
j
r
x
>x
Figure 7.3 Les deux cas pour une itération de la procédure PARTITION. (a) Si A[j] > x, l’unique
x, l’indice i est
action est d’incrémenter j, ce qui conserve l’invariant de boucle. (b) Si A[j]
incrémenté, A[i] et A[j] sont échangés, puis j est incrémenté. Ici aussi, l’invariant de boucle est
conservé.
7.1.3 Expliquer rapidement pourquoi le temps d’exécution de PARTITION sur un sous-tableau
de taille n est Q(n).
7.1.4 Comment pourrait-on modifier T RI -R APIDE pour trier en ordre décroissant ?
c Dunod – La photocopie non autorisée est un délit
7.2 PERFORMANCES DU TRI RAPIDE
Le temps d’exécution du tri rapide dépend du caractère équilibré ou non du partitionnement, qui dépend à son tour des éléments utilisés pour le partitionnement. Si
le partitionnement est équilibré, l’algorithme s’exécute asymptotiquement aussi vite
que le tri par fusion. En revanche, si le partitionnement n’est pas équilibré, le tri
risque d’être aussi lent que le tri par insertion. Dans cette section, nous allons étudier
de manière informelle les performances du tri rapide, selon que le partitionnement
est équilibré ou non.
a) Partitionnement dans le cas le plus défavorable
Le cas le plus défavorable intervient pour le tri rapide quand la routine de partitionnement produit un sous-problème à n − 1 éléments et une autre avec 0 élément. (Cette
affirmation est prouvée à la section 7.4.1.) Supposons que ce partitionnement nonéquilibré survienne à chaque appel récursif. Le partitionnement coûte Q(n). Comme
7 • Tri rapide
144
l’appel récursif sur un tableau de taille 0 rend la main sans rien faire, T(0) = Q(1) et
la récurrence pour le temps d’exécution est
T(n) = T(n − 1) + T(0) + Q(n)
= T(n − 1) + Q(n) .
Intuitivement, si l’on cumule les coûts induits à chaque niveau de la récursivité, on obtient une série arithmétique (équation (A.2)) dont la valeur est Q(n2 ). Effectivement,
la méthode de substitution prouve très simplement que la récurrence T(n) = T(n−1)+Q(n)
a pour solution T(n) = Q(n2 ). (Voir exercice 7.2.1.)
Du coup, si le partitionnement est déséquilibré au maximum à chaque niveau de
récursivité de l’algorithme, le temps d’exécution est Q(n2 ). Le temps d’exécution du
tri rapide n’est donc pas meilleur, dans le cas le plus défavorable, que celui du tri par
insertion. En outre, ce temps d’exécution de Q(n2 ) se produit quand le tableau d’entrée est déjà complètement trié, situation courante dans laquelle le tri par insertion
s’exécute en un temps O(n).
b) Partitionnement dans le cas le plus favorable
Dans le partitionnement le plus équilibré possible, PARTITION produit deux sousproblèmes de taille non supérieure à n/2, vu que l’un est de taille n/2 et l’autre de
taille n/2 − 1. En pareil cas, le tri rapide s’exécute beaucoup plus rapidement. La
récurrence du temps d’exécution est alors
T(n) 2T(n/2) + Q(n) ,
D’après le cas 2 du théorème général (théorème 4.1), la solution en est T(n) = O(n lg n).
Un partitionnement parfaitement équilibré à chaque niveau de la récursivité engendre
donc un algorithme plus rapide asymptotiquement.
c) Partitionnement équilibré
Le temps d’exécution moyen du tri rapide est beaucoup plus près du cas optimal que
du cas le plus défavorable, comme le montrera l’analyse faite à la section 7.4. Pour
en comprendre la raison, il est important de comprendre comment l’équilibrage du
partitionnement se répercute dans la récurrence qui décrit le temps d’exécution.
Supposons, par exemple, que l’algorithme de partitionnement produise systématiquement un découpage dans une proportion de 9 contre 1, ce qui paraît à première
vue assez déséquilibré. On obtient dans ce cas la récurrence
T(n) T(9n/10) + T(n/10) + cn
pour le temps d’exécution du tri rapide ; nous avons explicitement inclus la constante
c implicitement contenue dans le terme Q(n). La figure 7.4 montre l’arbre récursif de
cette récurrence. Remarquez que chaque niveau de l’arbre a un coût cn, jusqu’à ce
qu’une condition aux limites soit atteinte à la profondeur log10/9 n = Q(lg n), après
7.2
Performances du tri rapide
145
n
1
10
log10 n
1
100
n
cn
9
10
n
9
100
n
log10/9 n
1
9
100
n
cn
81
100
n
81
1000
n
n
729
1000
cn
n
cn
cn
1
cn
O(n lg n)
Figure 7.4 Arbre récursif de T RI -R APIDE dans lequel PARTITION produit toujours une décomposition 9-1, donnant ainsi un temps d’exécution O(nlgn). Les nœuds montrent les tailles de sous
problème, les coûts par niveau figurant à droite. Les coûts par niveau incluent la constante c
implicitement contenue dans le terme Q(n)
c Dunod – La photocopie non autorisée est un délit
quoi les niveaux ont un coût au plus égal à cn. La récursivité se termine à la profondeur log10/9 n = Q(lg n). Le coût total du tri rapide est donc O(n lg n). Donc, avec un
découpage 9-1 à chaque niveau de récursivité, ce qui semble intuitivement assez déséquilibré, le tri rapide s’exécute en O(n lg n), temps asymptotiquement identique à la
situation où le découpage se fait exactement au milieu. En fait, même un découpage
99-1 donne un temps d’exécution O(n lg n). La raison en est qu’un découpage ayant
un facteur de proportionnalité constant engendre toujours un arbre récursif de profondeur Q(lg n), où le coût de chaque niveau est O(n). Le temps d’exécution est donc
O(n lg n) chaque fois que le découpage s’effectue avec un facteur de proportionnalité
constant.
d) Intuition pour le cas moyen
Pour se faire une idée claire du cas moyen du tri rapide, on doit faire une hypothèse
sur la fréquence d’apparition escomptée des diverses entrées. Le comportement du
tri rapide est conditionné par l’ordre relatif des valeurs des éléments du tableau en
entrée, pas par les valeurs elles-mêmes. Comme dans notre analyse probabiliste du
problème de l’embauche (voir section 5.2), nous supposerons ici qu’il y a équiprobabilité pour toutes les permutations des nombres en entrée.
Lorsqu’on exécute le tri rapide sur un tableau aléatoire, il y a peu de chances
pour que le partitionnement se produise toujours de la même façon à chaque niveau,
comme nous l’avons supposé pour notre analyse informelle. On s’attend à ce que
7 • Tri rapide
146
n
Θ(n)
0
Θ(n)
n
n–1
(n–1)/2
(n–1)/2 – 1
(a)
(n–1)/2
(n–1)/2
(b)
Figure 7.5 (a) Deux niveaux d’un arbre récursif du tri rapide. Le partitionnement à la racine a
un coût n et produit un « mauvais » découpage : deux sous-tableaux de tailles 0 et n − 1. Le
partitionnement du sous-tableau de taille n − 1 coûte n − 1 et produit un « bon » découpage :
deux sous-tableaux de tailles (n − 1)/2 − 1 et (n − 1)/2. (b) Un niveau d’un arbre récursif qui est très
bien équilibré. Dans les deux parties, le coût de partitionnement des sous-problèmes (sur fond
elliptique gris) est Q(n). Pourtant, les sous-problèmes restant à résoudre en (a), affichés sur fond
carré gris, ne sont pas plus grands que les sous-problèmes correspondants qui restent à résoudre
en (b).
certains découpages soient assez bien équilibrés et que d’autres soient plutôt déséquilibrés. Par exemple, l’exercice 7.2.6 vous demandera de montrer que, dans environ 80 % des cas PARTITION produit un découpage qui est plus équilibré que 9-1 et
que, dans environ 20 % des cas elle produit un découpage moins équilibré que 9-1.
Dans le cas moyen, PARTITION produit un mélange de « bons » et de « mauvais »
découpages. Dans un arbre récursif associé du cas moyen de PARTITION, les bons et
les mauvais découpages sont distribués aléatoirement tout le long de l’arbre. Supposons pourtant, pour favoriser l’intuition, que les bons et les mauvais découpages se
succèdent d’un niveau de l’arbre à l’autre, que les bons découpages soient des découpages de cas optimal et que les mauvais découpages soient des découpages de cas le
plus défavorable. La figure 7.5(a) montre les découpages sur deux niveaux consécutifs de l’arbre récursif. A la racine de l’arbre, le coût est n pour le partitionnement
et les sous-tableaux produits ont les tailles n − 1 et 0 : cas le plus défavorable. Au
niveau suivant, le sous-tableau de taille n − 1 est partitionné de façon optimale en
deux sous-tableaux de tailles (n − 1)/2 − 1 et (n − 1)/2. Supposons que le coût de la
condition aux limites soit 1 pour le sous-tableau de taille 0.
Le mauvais découpage suivi du bon découpage produit trois sous-tableaux de
tailles 0, (n − 1)/2 − 1 et (n − 1)/2, pour un coût total de partitionnement
Q(n) + Q(n − 1) = Q(n). Cette situation n’est certainement pas pire que celle de
la figure 7.5(b), à savoir un niveau individuel de partitionnement qui produit deux
sous-tableaux de tailles (n − 1)/2 pour un coût Q(n). Et pourtant, cette dernière situation est équilibrée ! Intuitivement, le coût Q(n − 1) du mauvais découpage peut
être absorbé par le coût Q(n) du bon découpage et le découpage résultant est bon.
Ainsi, le temps d’exécution du tri rapide, quand les niveaux alternent entre bons et
mauvais découpages, est le même que si l’on a uniquement de bons découpages :
toujours O(n lg n), mais avec une constante implicite à la notation O qui est légèrement supérieure. Nous donnerons une analyse rigoureuse du cas moyen d’une version
randomisée du tri rapide à la section 7.4.2.
7.3
Versions randomisées du tri rapide
147
Exercices
7.2.1 Employer la méthode de substitution pour montrer que la récurrence T(n) = T(n−1)+Q(n)
a pour solution T(n) = Q(n2 ), comme affirmé au début de la section 7.2.
7.2.2 Quel est le temps d’exécution de T RI -R APIDE quand tous les éléments du tableau A
ont la même valeur ?
7.2.3 Montrer que le temps d’exécution de T RI -R APIDE est Q(n2 ) quand le tableau A contient
des éléments distincts triés en ordre décroissant.
7.2.4 Les banques enregistrent souvent les transactions dans l’ordre chronologique, mais
de nombreuses personnes aiment recevoir leurs relevés avec les chèques triés par numéros. En général, les gens signent les chèques dans l’ordre des numéros et les commerçants
les encaissent dans l’ordre d’arrivée. Le problème consistant à passer d’un ordre chronologique à un ordre basé sur les numéros de chèque relève donc du problème du tri de données
presque triées. Montrer que la procédure T RI -I NSERTION a tendance à battre la procédure
T RI -R APIDE sur ce problème.
7.2.5 On suppose que les découpages à chaque niveau du tri rapide se font dans la proportion
de 1 − a contre a, où 0 < a 1/2 est une constante. Montrer que la profondeur minimale
d’une feuille de l’arbre récursif est environ − lg n/ lg a et que la profondeur maximale est
environ − lg n/ lg(1 − a). (Ne pas se préoccuper des arrondis à la partie entière.)
7.2.6 Montrer que, quelle que soit la constante 0 < a 1/2, la probabilité est environ
1 − 2a pour que, sur un tableau d’entrée aléatoire, PARTITION produise un découpage plus
équilibré que 1 − a-a.
c Dunod – La photocopie non autorisée est un délit
7.3 VERSIONS RANDOMISÉES DU TRI RAPIDE
Pour l’étude du comportement du tri rapide dans le cas moyen, nous avions supposé
que toutes les permutations des nombres d’entrée étaient équiprobables. Dans la réalité, on ne peut pas toujours partir de cette hypothèse. (Voir exercice 7.2.4.) Comme
nous l’avons vu à la section 5.3, on peut parfois ajouter de la randomisation à un
algorithme pour obtenir de bonnes performances en moyenne. Beaucoup considèrent
la version randomisée du tri rapide comme l’algorithme de tri à privilégier pour des
entrées suffisamment grandes.
À la section 5.3, on randomisait l’algorithme en permutant explicitement l’entrée.
On pourrait faire pareil pour le tri rapide, mais une autre technique de randomisation,
dite échantillonnage aléatoire, simplifie l’analyse. Au lieu de toujours prendre A[r]
comme pivot, on prend un élément choisi aléatoirement dans le sous-tableau A[p . . r].
Pour ce faire, on échange A[r] avec un élément choisi au hasard dans A[p . . r]. Cette
modification, dans laquelle on échantillonne aléatoirement l’intervalle p, . . . , r, assure que l’élément pivot x = A[r] a des probabilités égales d’être l’un quelconque
148
7 • Tri rapide
des r − p + 1 éléments du sous-tableau. Le pivot étant choisi au hasard, on peut s’attendre à ce que le partitionnement du tableau d’entrée soit, en moyenne, relativement
équilibré.
Les modifications apportées à PARTITION et T RI -R APIDE sont mineures. Dans la
nouvelle procédure de partitionnement, on fait l’échange avant d’effectuer le partitionnement proprement dit :
PARTITION -R ANDOMISE(A, p, r)
1 i ← R ANDOM(p, r)
2 échanger A[r] ↔ A[i]
3 retourner PARTITION(A, p, r)
La nouvelle version du tri rapide appelle PARTITION -R ANDOMISE à la place de
PARTITION :
T RI -R APIDE -R ANDOMISE(A, p, r)
1 si p < r
2
alors q ← PARTITION -R ANDOMISE(A, p, r)
3
T RI -R APIDE -R ANDOMISE(A, p, q − 1)
4
T RI -R APIDE -R ANDOMISE(A, q + 1, r)
Nous analyserons cet algorithme dans la prochaine section.
Exercices
7.3.1 Pourquoi analysons-nous les performances d’un algorithme randomisé pour le cas
moyen et non pour le cas le plus défavorable ?
7.3.2 Pendant l’exécution de la procédure T RI -R APIDE -R ANDOMISE, combien y a-t-il d’appels au générateur de nombre aléatoires R ANDOM dans le cas le plus défavorable ? Et dans
le cas optimal ? Donner des réponses en terme de notation Q.
7.4 ANALYSE DU TRI RAPIDE
La section 7.2 a donné une idée du comportement du tri rapide dans le cas le plus
défavorable, ainsi que de sa rapidité d’exécution générale. Dans cette section, nous
allons analyser le comportement du tri rapide de façon plus rigoureuse. Nous commencerons par une analyse du cas le plus défavorable, qui s’appliquera aussi bien à
T RI -R APIDE qu’à T RI -R APIDE -R ANDOMISE et terminerons par une analyse du cas
moyen de T RI -R APIDE -R ANDOMISE.
7.4
Analyse du tri rapide
149
7.4.1 Analyse du cas le plus défavorable
Nous avons vu, à la section 7.2, qu’un découpage le plus défavorable à chaque niveau
de récursivité engendre un temps d’exécution Q(n2 ) qui, intuitivement, est le temps
d’exécution de l’algorithme dans le cas le plus défavorable. Nous allons à présent
démontrer cette assertion.
A l’aide de la méthode de substitution (voir section 4.1), on peut montrer que
le temps d’exécution du tri rapide est O(n2 ). Soit T(n) le temps d’exécution de la
procédure T RI -R APIDE dans le cas le plus défavorable sur une entrée de taille n. On
a la récurrence
T(n) =
max (T(q) + T(n − q − 1)) + Q(n) ,
0 q n−1
(7.1)
où le paramètre q est dans l’intervalle 0 à n − 1, puisque la procédure PARTITION
génère deux sous-problèmes de taille totale n − 1. Nous subodorons que T(n) cn2
pour une certaine constante c. En intégrant cette conjecture à la récurrence (7.1), nous
avons
T(n) =
max (cq2 + c(n − q − 1)2 ) + Q(n)
0 q n−1
c· max (q2 + (n − q − 1)2 ) + Q(n) .
0 q n−1
L’expression q2 + (n − q − 1)2 atteint un maximum en chaque extrémité de l’intervalle
de paramètre 0 q n − 1, car la dérivée seconde de l’expression par rapport à q
est positive (voir exercice 7.4.3). Cette observation fournit la borne
max (q2 + (n − q − 1)2 ) (n − 1)2 = n2 − 2n + 1.
0 q n−1
En continuant notre majoration de T(n), nous obtenons
c Dunod – La photocopie non autorisée est un délit
T(n) cn2 − c(2n − 1) + Q(n)
cn2 ,
vu que nous pouvons choisir la constante c suffisamment grande pour que le terme
c(2n − 1) domine le terme Q(n). Donc, T(n) = O(n2 ). Nous avons vu, à la section 7.2,
un cas spécial où le tri rapide prend un temps V(n2 ) : quand le partitionnement est
déséquilibré. À titre d’alternative, l’exercice 7.4.1 vous demandera de montrer que la
récurrence (7.1) a une solution de T(n) = V(n2 ). Ainsi, le temps d’exécution (le plus
défavorable) du tri rapide est Q(n2 ).
7.4.2 Temps d’exécution attendu
Nous avons déjà donné une justification intuitive du fait que le temps d’exécution
de T RI -R APIDE -R ANDOMISE, dans le cas moyen, est O(n lg n) : si, à chaque niveau
de récursivité, le découpage induit par PARTITION -R ANDOMISE place une fraction
constante des éléments sur un côté de la partition, alors l’arbre récursif a une profondeur Q(lg n) et il y a exécution en O(n) à chaque niveau. Même si l’on ajoute de
7 • Tri rapide
150
nouveaux niveaux avec le découpage le plus déséquilibré qui soit entre ces niveaux, le
temps total sera toujours O(n lg n). On peut analyser, de manière très précise, l’espérance du temps d’exécution de T RI -R APIDE -R ANDOMISE en commençant par comprendre le fonctionnement de la procédure de partitionnement, puis en utilisant cette
connaissance pour déduire une borne O(n lg n) pour l’espérance du temps d’exécution. Ce majorant du temps d’exécution attendu, combiné avec la borne Q(n lg n) du
cas optimal vue à la section 7.2, donne un temps d’exécution attendu de Q(n lg n).
a) Temps d’exécution et comparaisons
Le temps d’exécution de T RI -R APIDE est dominé par le temps consommé dans la
procédure PARTITION. Chaque fois que PARTITION est appelée, il y a sélection d’un
élément pivot ; cet élément ne figurera jamais dans les appels récursifs suivants à T RI R APIDE et à PARTITION. Il ne peut donc y avoir que n appels au plus à PARTITION
pendant l’exécution de l’algorithme du tri rapide. Un appel à PARTITION prend O(1),
plus un temps qui est proportionnel au nombre d’itérations de la boucle pour des
lignes 3–6. Chaque itération de cette boucle effectue une comparaison en ligne 4,
comparant le pivot à un autre élément du tableau A. Par conséquent, si nous pouvons
compter le nombre total de fois que la ligne 4 est exécutée, alors nous pourrons
borner le temps total consommé dans la boucle pour lors de l’exécution de T RI R APIDE.
Lemme 7.1 Soit X le nombre de comparaisons effectuées sur la ligne 4 de PARTITION
pendant toute l’exécution de T RI -R APIDE pour un tableau à n éléments. Alors, le
temps d’exécution de T RI -R APIDE est O(n + X).
Démonstration : Il ressort de la discussion précédente qu’il y a n appels à PARTI TION, dont chacun fait un volume constant de travail puis exécute la boucle pour un
certain nombre de fois. Chaque itération de la boucle pour exécute la ligne 4.
❑
Il nous faut donc calculer X, nombre total de comparaisons effectuées sur l’ensemble des appels à PARTITION. Nous n’essaierons pas d’analyser le nombre de
comparaisons qui sont faites dans chaque appel à PARTITION. Nous allons plutôt
déterminer une borne globale pour le nombre total de comparaisons. Pour ce faire,
nous devons comprendre dans quel cas l’algorithme compare deux éléments du tableau et dans quel cas il ne les compare pas. Pour simplifier l’analyse, renommons
les éléments du tableau A comme z1 , z2 , . . . , zn , où zi est le i-ème plus petit élément.
Nous définissons aussi Zij = {zi , zi+1 , . . . , zj } comme étant l’ensemble des éléments
compris entre zi et zj inclus.
Quand est-ce que l’algorithme compare zi et zj ? Pour répondre a cette question,
observons d’abord que chaque paire d’éléments est testée une fois au plus. Pourquoi
donc ? Les éléments ne sont comparés qu’au pivot ; or, après que l’appel à PARTITION
s’est terminé, le pivot employé dans cet appel n’est plus jamais comparé aux autres
éléments.
7.4
Analyse du tri rapide
151
Notre analyse utilise des variables indicatrices (voir section 5.2). Soit
Xij = I {zi est comparé à zj } ,
Nous voulons savoir si la comparaison a lieu à n’importe quel moment pendant l’exécution de l’algorithme, et pas seulement pendant une itération ou un appel individuel
à PARTITION. Comme chaque paire n’est testée qu’une fois au plus, on peut facilement caractériser le nombre total de comparaisons effectuées par l’algorithme :
X=
n−1 n
Xij .
i=1 j=i+1
En prenant les espérances des deux côtés, puis en utilisant la linéarité de l’espérance
et le lemme 5.1, on obtient
n−1 n
Xij
E [X] = E
i=1 j=i+1
=
n−1 n
E [Xij ]
i=1 j=i+1
=
n−1
n
Pr {zi est comparé à zj } .
(7.2)
i=1 j=i+1
Reste à calculer Pr {zi est comparé à zj }.
c Dunod – La photocopie non autorisée est un délit
Il est utile de se demander dans quel cas il n’y a pas comparaison de deux éléments.
Supposons que le tri rapide doive trier les nombres 1 à 10 (rangés dans n’importe quel
ordre) et que le premier pivot soit 7. Alors, le premier appel à PARTITION divise les
nombre en deux ensembles : {1, 2, 3, 4, 5, 6} et {8, 9, 10}. Ce faisant, le pivot 7 est
comparé à tous les autres éléments, mais aucun nombre du premier ensemble (par
exemple 2) n’est jamais comparé à un nombre quelconque du second ensemble (par
exemple 9).
En général, une fois choisi un pivot x tel que zi < x < zj , on sait que zi et zj ne
seront jamais comparés par la suite. Si, en revanche, zi est choisi comme pivot avant
tout autre élément de Zij , alors zi sera comparé à chaque élément de Zij , sauf à luimême. De même, si zj est choisi comme pivot avant tout autre élément de Zij , alors
zj sera comparé à chaque élément de Zij , sauf à lui-même. Dans notre exemple, les
valeurs 7 et 9 sont comparées car 7 est le premier élément de Z7,9 à être choisi comme
pivot. En revanche, 2 et 9 ne seront jamais comparés parce que le premier pivot choisi
dans Z2,9 est 7. Donc, zi et zj sont comparés si et seulement si le premier élément à
être choisi comme pivot dans Zij est zi ou zj .
Calculons maintenant la probabilité de cet événement. Avant qu’un élément de Zij
ne soit choisi comme pivot, tout l’ensemble Zij est dans la même partition. Donc,
chaque élément de Zij a la même chance d’être le premier élément choisi comme
7 • Tri rapide
152
pivot. Comme Zij a j − i + 1 éléments et que les pivots sont choisis aléatoirement et
de manière indépendante, la probabilité qu’un élément donné soit le premier à être
choisi comme pivot est 1/(j − i + 1). On a donc
Pr {zi est comparé à zj } = Pr {zi ou zj est le premier pivot choisi dans Zij }
= Pr {zi est le premier pivot choisi dans Zij }
+ Pr {zj est le premier pivot choisi dans Zij }
1
1
=
+
j−i+1 j−i+1
2
.
(7.3)
=
j−i+1
La deuxième ligne découle de ce que les deux événements sont mutuellement exclusifs. En combinant les équations (7.2) et (7.3), on obtient
E [X] =
n−1 n
i=1 j=i+1
2
.
j−i+1
On peut calculer cette somme à l’aide d’un changement de variables (k = j − i) et
d’une borne de la série harmonique (équation (A.7)) :
E [X]
=
n−1 n
i=1 j=i+1
=
n−1 n−i
i=1 k=1
<
=
2
j−i+1
2
k+1
n−1 n
2
i=1 k=1
n−1
k
O(lg n)
i=1
=
O(n lg n) .
(7.4)
En conclusion, en employant PARTITION -R ANDOMISE on arrive à un temps d’exécution attendu de O(n lg n) pour le tri rapide.
Exercices
7.4.1 Montrer que, dans la récurrence
T(n) =
T(n) = V(n2 ).
max (T(q) + T(n − q − 1)) + Q(n) ,
0qn−1
Problèmes
153
7.4.2 Montrer que le temps d’exécution du tri rapide, dans le meilleur des cas, est V(n lg n).
7.4.3 Montrer que q2 + (n − q − 1)2 atteint un maximum sur l’intervalle q = 0, 1, . . . , n − 1
quand q = 0 ou q = n − 1.
7.4.4 Montrer que le temps d’exécution attendu de T RI -R APIDE -R ANDOMISE est V(n lg n).
7.4.5 Le temps d’exécution du tri rapide peut être amélioré, en pratique, en tirant avantage du
temps d’exécution efficace du tri par insertion lorsque l’entrée est « presque » triée. Lorsque le
tri rapide est appelé sur un sous-tableau ayant moins de k éléments, il rend la main sans trier le
sous-tableau. Après que l’appel de premier niveau du tri rapide a rendu la main, on lance le tri
par insertion sur le tableau entier pour terminer le traitement. Démontrer que cet algorithme
s’exécute avec un temps attendu O(nk + n lg(n/k)). Comment faut-il choisir k, en théorie et
en pratique ? Soit la modification suivante de la procédure PARTITION : on choisit au hasard
trois éléments du tableau A, puis on effectue le partitionnement autour de l’élément médian
(valeur du milieu). Approximer la probabilité d’obtenir au pire un découpage a-(1 − a), en
tant que fonction de a dans l’intervalle 0 < a < 1.
PROBLÈMES
c Dunod – La photocopie non autorisée est un délit
7.1. Validité du partitionnement de Hoare
La version de PARTITION donnée dans ce chapitre n’est pas l’algorithme de partitionnement originel. Voici la version d’origine, due à C.A.R. Hoare :
H OARE -PARTITION(A, p, r)
1 x ← A[p]
2 i←p−1
3 j←r+1
4 tant que VRAI
5
faire répéter j ← j − 1
6
jusqu’à A[j] x
7
répéter i ← i + 1
8
jusqu’à A[i] x
9
si i < j
10
alors échanger A[i] ↔ A[j]
11
sinon retourner j
a. Montrer le bon fonctionnement de H OARE -PARTITION sur le tableau A = 13,
19, 9, 5, 12, 8, 7, 4, 11, 2, 6, 21, en donnant les valeurs du tableau et les valeurs
auxiliaires après chaque itération de la boucle tant que des lignes 4–11.
Les trois questions suivantes vont vous donner l’occasion de démontrer très précisément que la procédure H OARE -PARTITION est correcte. Prouver les points suivants :
7 • Tri rapide
154
b. Les indices i et j sont tels que l’on n’accède jamais à un élément de A qui soit en
dehors du sous-tableau A[p . . r].
c. Quand H OARE -PARTITION se termine, elle retourne une valeur j telle que p j < r.
d. Chaque élément de A[p . . j] est inférieur ou égal à chaque élément de A[j + 1 . . r]
quand H OARE -PARTITION se termine.
La procédure PARTITION de la section 7.1 sépare le pivot (originellement en A[r]) des
deux partitions qu’elle crée. La procédure H OARE -PARTITION, en revanche, place
toujours le pivot (originellement en A[p]) dans l’une des deux partitions A[p . . j] et
A[j + 1 . . r]. Comme p j < r, ce découpage n’est jamais trivial.
e. Réécrire la procédure T RI -R APIDE pour qu’elle utilise H OARE -PARTITION.
7.2. Autre analyse du tri rapide
Une autre façon d’analyser le temps d’exécution du tri rapide randomisé s’appuie sur
le temps d’exécution attendu de chaque appel récursif à T RI -R APIDE, et non sur le
nombre de comparaisons effectuées.
a. Prouver que, étant donné un tableau de taille n, la probabilité qu’un quelconque
élément soit choisi comme pivot est 1/n. Utilisez ce fait pour définir des variables
aléatoires indicatrices Xi = I {le i-ème plus petit élément est choisi comme pivot}.
Que vaut E [Xi ] ?
b. Soit T(n) une variable aléatoire désignant le temps d’exécution du tri rapide sur
un tableau de taille n. Prouver que
n
(7.5)
Xq (T(q − 1) + T(n − q) + Q(n)) .
E [T(n)] = E
q=1
c. Montrer que l’équation (7.5) peut être réécrite sous la forme
2
E [T(q)] + Q(n) .
n
n−1
E [T(n)] =
(7.6)
q=2
d. Montrer que
n−1
k=2
1
1
k lg k n2 lg n − n2 .
2
8
(7.7)
(conseil : Diviser la somme en deux parties, une pour k = 2, 3, . . . , n/2 − 1 et
l’autre pour k = n/2 , . . . , n − 1.)
e. En utilisant la borne établie dans l’équation (7.7), montrer que la récurrence dans
l’équation (7.6) a pour solution E [T(n)] = Q(n lg n). (Conseil : Montrer, par substitution, que E [T(n)] an log n, pour n suffisamment grand et pour une certaine
constante positive a.)
Problèmes
155
7.3. Le tri faire-valoir
Les professeurs Croquignol, Ribouldingue et Filochard proposent l’algorithme « élégant » que voici :
T RI -FAIRE -VALOIR(A, i, j)
1 si A[i] > A[j]
2
alors échanger A[i] ↔ A[j]
3 si i + 1 j
4
alors retourner
5 k ← (j − i + 1)/3
6 T RI -FAIRE -VALOIR(A, i, j − k)
7 T RI -FAIRE -VALOIR(A, i + k, j)
8 T RI -FAIRE -VALOIR(A, i, j − k)
arrondi inférieur.
deux premiers tiers.
deux derniers tiers.
deux premiers tiers derechef.
a. Démontrer que T RI -FAIRE -VALOIR(A, 1, longueur[A]) trie correctement le tableau
A[1 . . n], si n = longueur[A].
b. Donner une récurrence pour le temps d’exécution, dans le pire des cas, de T RI FAIRE -VALOIR et une borne asymptotique serrée (en notation Q) pour le temps
d’exécution du cas le plus défavorable.
c. Comparer le temps d’exécution de T RI -FAIRE -VALOIR, dans le cas le plus défavorable, avec les temps d’exécution du tri par insertion, du tri par fusion, du tri par
tas et du tri rapide. Les professeurs méritent-ils leur titre ?
c Dunod – La photocopie non autorisée est un délit
7.4. Profondeur de pile du tri rapide
L’algorithme T RI -R APIDE de la section 7.1 contient deux appels récursifs à luimême. Après l’appel à PARTITION, le sous-tableau de gauche est trié récursivement,
puis le sous-tableau de droite est trié récursivement. Le second appel récursif à T RI R APIDE n’est pas vraiment nécessaire ; on peut l’éviter en utilisant une structure de
contrôle itérative. Cette technique, appelée récursivité terminale, est utilisée automatiquement par les bons compilateurs. On considère la version suivante du tri rapide,
qui simule la récursivité terminale.
T RI -R APIDE (A, p, r)
1 tant que p < r
2
faire Partitionne et trie sous-tableau gauche.
3
q ← PARTITION(A, p, r)
4
T RI -R APIDE (A, p, q − 1)
5
p←q+1
a. Démontrer que T RI -R APIDE (A, 1, longueur[A]) trie correctement le tableau A.
Les compilateurs exécutent généralement les procédures récursives en utilisant une
pile qui contient les informations pertinentes, notamment les valeur des paramètres,
156
7 • Tri rapide
pour chaque appel récursif. Les données de l’appel le plus récent se trouvent au sommet de la pile, et celles du premier appel sont en bas. Lorsqu’une procédure est invoquée, ses informations sont empilées ; lorsqu’elle se termine, elles sont dépilées.
Comme on suppose que les paramètres tableau sont représentés par des pointeurs,
les données de chaque appel de procédure nécessitent un espace de pile O(1). La
profondeur de pile est la quantité maximale d’espace de pile utilisée lors d’un calcul.
b. Décrire un scénario dans lequel la profondeur de pile de T RI -R APIDE est Q(n) sur
un tableau à n éléments.
c. Modifier le code de T RI -R APIDE pour que la profondeur de pile, dans le cas le
plus défavorable, soit Q(lg n). Conserver le temps d’exécution attendu de O(n lg n)
pour l’algorithme.
7.5. Partition autour du nombre médian
Une façon d’améliorer la procédure T RI -R APIDE -R ANDOMISE est d’effectuer le partitionnement autour d’un pivot qui est choisi d’une manière plus fine que la sélection aléatoire d’un élément dans le sous-tableau. Un approche fréquente est la méthode du médian de 3 : on choisit comme pivot le médian (élément du milieu) d’un
ensemble de trois éléments sélectionnés aléatoirement dans le sous-tableau. (Voir
exercice 7.4.6.) Pour ce problème, on suppose que les éléments du tableau d’entrée
A[1 . . n] sont distincts et que n 3. On appelle A [1 . . n] le tableau trié en sortie. En
utilisant la méthode du médian de 3 pour choisir le pivot, définir pi = Pr {x = A [i]}.
a. Donner une formule exacte pour pi , sous la forme d’une fonction de n et de i pour
i = 2, 3, . . . , n − 1. (Notez que p1 = pn = 0.)
b. De combien améliore-t-on la probabilité de choisir comme pivot x = A [ (n + 1)/2 ],
médian de A[1 . . n], par rapport à l’implémentation ordinaire ? On supposera que
n → ∞ et on donnera le rapport limite de ces probabilités.
c. Si l’on définit un « bon » découpage comme signifiant le choix comme pivot de
x = A [i], où n/3 i 2n/3, de combien a-t-on amélioré les chances d’obtenir
un bon découpage, par rapport à l’implémentation ordinaire ? (Conseil : Faire une
approximation de la somme par une intégrale.)
d. Prouver que, dans le temps d’exécution V(n lg n) du tri rapide, la méthode du
médian de 3 n’affecte que le facteur constant.
7.6. Tri flou d’intervalles
On considère un problème de tri dans lequel les nombres ne sont pas connus avec
précision : pour chacun des nombres, on connaît un intervalle de réels auquel appartient le nombre. En d’autres termes, on a n intervalles fermés de la forme [ai , bi ], avec
ai bi . Le but est de faire un tri flou de ces intervalles, c’est à dire de produire une
permutation i1 , i2 , . . . , in des intervalles, telle qu’il existe cj ∈ [aij , bij ] qui satisfasse
à c1 c2 · · · cn .
Notes
157
a. Concevoir un algorithme pour le tri flou de n intervalles. L’algorithme devra avoir
l’allure générale d’un algorithme qui fait du tri rapide sur les extrémités gauche
(les ai ), mais il devra exploiter les recoupements d’intervalles pour améliorer le
temps d’exécution. (Quand les intervalles se recoupent de plus en plus, le problème du tri flou devient de plus en plus facile. L’algorithme devra utiliser les
recoupements, pour autant qu’il y en ait.)
b. Prouver que votre algorithme tourne avec le temps attendu Q(n lg n) en général,
mais avec le temps attendu Q(n) quand tous les intervalles se recoupent (c’est-àdire, quand il existe une valeur x telle que x ∈ [ai , bi ] pour tout i). Votre algorithme ne doit pas tester ce cas explicitement ; il faut plutôt que les performances
augmentent naturellement à mesure qu’augmente le volume des recoupements.
NOTES
c Dunod – La photocopie non autorisée est un délit
Le tri rapide a été inventé par Hoare [147] ; la version de Hoare apparaît au problème 7.1. La
procédure PARTITION de la section 7.1 est due à N. Lomuto. L’analyse faite à la section 7.4
est due à Avrim Blum. Sedgewick [268] et Bentley [40] sont de bonnes références sur les
détails d’implémentation et leur importance.
McIlroy [216] a montré comment créer un « adversaire tueur » qui produit un tableau pour
lequel presque toutes les implémentations du tri rapide prennent un temps Q(n2 ). Si l’implémentation est randomisée, l’adversaire produit le tableau après avoir vu les choix aléatoires
de l’algorithme de tri rapide.
Chapitre 8
c Dunod – La photocopie non autorisée est un délit
Tri en temps linéaire
Nous avons présenté jusqu’ici plusieurs algorithmes capables de trier n nombres en
O(n lg n). Le tri par fusion et le tri par tas atteignent ce majorant dans le cas le plus
défavorable ; pour le tri rapide, cette borne est atteinte dans le cas moyen. Par ailleurs,
on peut fournir à chacun de ces algorithmes une séquence de n nombres qui provoque
l’exécution de l’algorithme dans un temps V(n lg n).
Ces algorithmes ont en commun une propriété intéressante : le tri qu’ils effectuent repose uniquement sur des comparaisons entre les éléments d’entrée. Ces algorithmes de tri sont appelés tris par comparaison. Tous les algorithmes de tri étudiés
jusqu’ici sont des tris par comparaison.
Dans la section 8.1, nous démontrerons qu’un tri par comparaison doit effectuer
au pire V(n lg n) comparaisons pour trier n éléments. Le tri par fusion et le tri par tas
sont donc asymptotiquement optimaux et il n’existe aucun tri par comparaison qui
les domine de plus d’un facteur constant.
Les sections 8.2, 8.3 et 8.4 examinent trois algorithmes, le tri par dénombrement,
le tri par base et le tri par paquets qui s’exécutent dans un temps linéaire. Il va de
soi que ces algorithmes font appel à des opérations autres que des comparaisons. Le
minorant V(n lg n) ne les concerne donc pas.
8.1 MINORANTS POUR LE TRI
Dans un tri par comparaison, on se sert uniquement de comparaisons d’éléments
pour obtenir des informations sur l’ordre d’une séquence d’entrée a1 , a2 , . . . , an .
8 • Tri en temps linéaire
160
Autrement dit, étant donnés deux éléments ai et aj , on effectue l’un des tests ai < aj ,
ai aj , ai = aj , ai aj ou ai > aj pour déterminer leur ordre relatif.
Dans cette section, on supposera sans nuire à la généralité que tous les éléments
d’entrée sont distincts. Grâce à cette hypothèse, les comparaisons de la forme ai = aj
deviennent inutiles et on peut donc supposer qu’aucune comparaison de ce type n’est
effectuée. On remarque également que les comparaisons ai aj , ai aj , ai > aj
et ai < aj sont toutes équivalentes, en ce sens où elles fournissent des informations
identiques concernant l’ordre relatif de ai et aj . On suppose donc que toutes les comparaisons seront de la forme ai aj .
a) Modèle d’arbre de décision
Les tris par comparaison peuvent être considérés de façon abstraite en termes
d’arbres de décision. Un arbre de décision est un arbre binaire plein qui représente
les comparaisons entre éléments effectuées par un algorithme de tri lorsqu’il traite
une entrée d’une taille donnée. Instructions de contrôle, transferts de données et
tous les autres aspects de l’algorithme sont ignorés. La figure 8.1 montre l’arbre de
décision qui correspond à l’algorithme du tri par insertion (section 2.1) exécuté sur
une séquence de trois éléments.
1:2
≤
>
>
≤
2:3
1:3
≤
⟨1,2,3⟩
⟨2,1,3⟩
1:3
≤
⟨1,3,2⟩
>
>
⟨3,1,2⟩
2:3
≤
⟨2,3,1⟩
>
⟨3,2,1⟩
Figure 8.1 Arbre de décision pour un tri par insertion opérant sur trois éléments. Un nœud
interne annoté par i :j indique une comparaison entre ai et aj . Une feuille annotée par la permutation p(1), p(2), . . . , p(n) indique l’ordre ap(1)
ap(2)
···
ap(n) . Le chemin ombré indique
les décisions faites lors du tri de la séquence a1 = 6, a2 = 8, a3 = 5 ; la permutation 3, 1, 2 au
a1 = 6
a2 = 8. Il y a 3! = 6 permutaniveau de la feuille indique que l’ordre trié est a3 = 5
tions possibles pour les éléments en entrée, de sorte que l’arbre de décision doit avoir au moins
6 feuilles.
Dans un arbre de décision, chaque nœud interne est étiqueté par ai : aj pour un
certain i et un certain j de l’intervalle 1 i, j n, où n est le nombre d’éléments de
la séquence d’entrée. Chaque feuille est étiquetée par une permutation p(1), p(2),
. . . , p(n). (Voir section C.1 pour un rappel sur les permutations.) L’exécution de
l’algorithme de tri suit un chemin qui part de la racine de l’arbre de décision pour
aboutir à une feuille. Sur chaque nœud interne, on effectue une comparaison ai aj .
Les comparaisons suivantes auront lieu dans le sous-arbre gauche si ai aj et dans
le sous-arbre droit si ai > aj . Quand on arrive sur une feuille, l’algorithme de tri
8.1
Minorants pour le tri
161
a établi l’ordre ap(1) ap(2) · · · ap(n) . Comme tout algorithme de tri correct
doit être capable de produire toutes les permutations possibles de son entrée, une
condition nécessaire pour qu’un tri par comparaison soit correct est que chacune des
n! permutations sur n éléments doit apparaître en tant que l’une des feuilles de l’arbre
de décision et que chacune de ces feuilles doit être accessible depuis la racine via
un chemin qui corresponde à une exécution concrète du tri par comparaison. (Nous
dirons de ces feuilles qu’elles sont « accessibles ».) Nous ne considérerons donc que
les arbres de décision dans lesquels chaque permutation figure en tant que feuille
accessible.
b) Minorant pour le cas le plus défavorable
La longueur du plus long chemin reliant la racine d’un arbre de décision à l’une quelconque de ses feuilles accessibles représente le nombre de comparaisons effectuées
par l’algorithme de tri dans le cas le plus défavorable. Le nombre de comparaisons
du cas le plus défavorable est donc égal, pour un algorithme de tri par comparaison
donné, à la hauteur de son arbre de décision. Une minorant pour les hauteurs de tous
les arbres de décision dans lesquels chaque permutation apparaît en tant que feuille
accessible est donc un minorant du temps d’exécution pour n’importe quel algorithme
de tri par comparaison. Le théorème suivant calcule un tel minorant.
Théorème 8.1 Tout algorithme de tri par comparaison exige V(n lg n) comparaisons
dans le cas le plus défavorable.
Démonstration : La discussion précédente montre qu’il suffit de déterminer la hauteur d’un arbre de décision dans lequel chaque permutation apparaît en tant que feuille
accessible. Considérons un arbre de décision de hauteur h avec l feuille accessible correspondant à un tri par comparaison sur n éléments. Comme chacune des n! permutations de l’entrée apparaît sous la forme d’une certaine feuille, on a n! l. Puisqu’un
arbre binaire de hauteur h n’a pas plus de 2h feuilles, on a
c Dunod – La photocopie non autorisée est un délit
n! l 2h ,
ce qui, en prenant les logarithmes, implique
h lg(n!)
(car la fonction lg est monotone croissante)
= V(n lg n) (d’après l’équation (3.18)) .
❑
Corollaire 8.2 Le tri par tas et le tri par fusion sont des tris par comparaison asymp-
totiquement optimaux.
Démonstration : Les majorants O(n lg n) des temps d’exécution du tri par tas et du
tri par fusion correspondent au minorant du cas le plus défavorable, minorant qui est
V(n lg n) d’après le théorème 8.1.
❑
Exercices
8.1.1 Quelle est la plus petite profondeur possible d’une feuille dans un arbre de décision
d’un tri par comparaison ?
162
8 • Tri en temps linéaire
8.1.2 Obtenir des bornes asymptotiquement serrées pour lg(n!) sans utiliser l’approximan
tion de Stirling. Évaluer plutôt la sommation k=1 lg k à l’aide des techniques vues à la
section A.2.
8.1.3 Montrer qu’il n’existe aucun tri par comparaison dont le temps d’exécution soit linéaire pour au moins la moitié des n! entrées possibles de longueur n. Qu’en est-il pour une
fraction 1/n des entrées de longueur n ? Et pour une fraction 1/2n ?
8.1.4 Soit à trier une séquence de n éléments. La séquence est constituée de n/k sousséquences, chacune contenant k éléments. Les éléments d’une sous-séquence donnée sont
tous plus petits que les éléments de la sous-séquence suivante et tous plus grands que les
éléments de la sous-séquence précédente. Pour trier la séquence complète de longueur n, il
suffit donc de trier les k éléments de chacune des n/k sous-séquences. Prouver l’existence
d’un minorant V(n lg k) pour le nombre de comparaisons requis par la résolution de cette
variante du problème de tri. (Conseil : Se contenter de combiner les minorants des diverses
sous-séquences n’est pas une méthode rigoureuse.)
8.2 TRI PAR DÉNOMBREMENT
Le tri par dénombrement suppose que chacun des n éléments de l’entrée est un entier de l’intervalle 0 à k, k étant un certain nombre entier. Lorsque k = O(n), le tri
s’exécute en un temps Q(n).
Le principe du tri par dénombrement est de déterminer, pour chaque élément x de
l’entrée, le nombre d’éléments inférieurs à x. Cette information peut servir à placer
l’élément x directement à sa position dans le tableau de sortie. Par exemple, s’il existe
17 éléments inférieurs à x, alors x se trouvera en sortie à la position 18. Ce schéma
doit être légèrement modifié pour gérer la situation dans laquelle plusieurs éléments
ont la même valeur, puisqu’on ne veut pas tous les placer à la même position.
Dans le code du tri par dénombrement, on suppose que l’entrée est un tableau
A[1 . . n] et donc que longueur[A] = n. Nous avons besoin de deux autres tableaux : le
tableau B[1 . . n] contient la sortie triée et le tableau C[0 . . k] sert d’espace de stockage
temporaire.
T RI -D ÉNOMBREMENT(A, B, k)
1 pour i ← 0 à k
2
faire C[i] ← 0
3 pour j ← 1 à longueur[A]
4
faire C[A[j]] ← C[A[j]] + 1
5 C[i] contient maintenant le nombre d’éléments égaux à i.
6 pour i ← 1 à k
7
faire C[i] ← C[i] + C[i − 1]
8 C[i] contient maintenant le nombre d’éléments inférieurs ou égaux à i.
9 pour j ← longueur[A] jusqu’à 1
10
faire B[C[A[j]]] ← A[j]
11
C[A[j]] ← C[A[j]] − 1
8.2
Tri par dénombrement
163
La figure 8.2 illustre le tri par dénombrement. Après initialisation de la boucle pour
des lignes 1–2, on regarde chaque élément de l’entrée dans la boucle pour des lignes
3–4. Si la valeur d’un élément est i, on incrémente C[i]. Ainsi, après La ligne 4,
C[i] contient le nombre d’éléments égaux à i, pour tout entier i = 0, 1, . . . , k. Dans
les lignes 6–7, on détermine, pour i = 0, 1, . . . , k, le nombre d’éléments qui sont
inférieurs ou égaux à i et ce en gérant un cumul constamment actualisé du tableau C.
1
2
3
4
5
6
A 2
5
3
0
2
3 0 3
7
8
0
1
2
3
4
5
C 2
0
2
3
0
1
1
4
5
C 2 2 4 7 7
0
1
2
8
1
2
3
4
5
6
0
7
3
0
1
2
3
4
5
C 1
2
4
6
7
8
(d)
3
8
1
B
2
3
4
5
0
0
1
3
6
4
5
C 1 2 4 5 7
8
(e)
5
6
7
8
0
1
2
3
4
5
C 2
2
4
6
7
8
(c)
7
3 3
2
4
3
(b)
(a)
B
3
2
B
8
2
3
4
5
7
8
B 0 0
1
2
2
3 3 3
6
5
(f)
Figure 8.2 Fonctionnement de T RI -D ÉNOMBREMENT sur un tableau A[1 . . 8], où chaque élément
de A est un entier positif pas plus grand que k = 5. (a) Le tableau A et le tableau auxiliaire C après
la ligne 4. (b) Le tableau C après la ligne 7. (c)–(e) Le tableau en sortie B et le tableau auxiliaire C
après une, deux et trois itérations de la boucle des lignes 9–11. Seules les cases en gris clair du
tableau B ont été remplies. (f) Le tableau résultant final B.
c Dunod – La photocopie non autorisée est un délit
Enfin, dans la boucle pour des lignes 9–11, on place chaque élément A[j] à sa
bonne place dans le tableau de sortie B. Si les n éléments sont tous distinct, alors
quand on arrive pour la première fois sur la ligne 9, pour chaque A[j] la valeur C[A[j]]
est la position finale correcte de A[j] dans le tableau de sortie, car il y a C[A[j]] éléments inférieurs ou égaux à A[j]. Comme les éléments pourraient ne pas être distincts,
on décrémente C[A[j]] chaque fois que l’on place une valeur A[j] dans le tableau B.
Décrémenter C[A[j]] entraîne que le prochain élément qui a une valeur égale à A[j],
s’il y en a un, ira à la position située juste avant A[j] dans le tableau de sortie.
Combien de temps consomme le tri par dénombrement ? La boucle pour des lignes
1–2 prend un temps Q(k), la boucle pour des lignes 3–4 prend un temps Q(n), la
boucle pour des lignes 6–7 prend un temps Q(k) et la boucle pour des lignes 9–11
prend un temps Q(n). Ainsi, le temps global est Q(k + n). En pratique, on utilise généralement le tri par dénombrement quand k = O(n), auquel cas le temps d’exécution
est Q(n).
Le tri par dénombrement améliore le minorant V(n lg n) établi à la section 8.1 car
ce n’est pas un tri par comparaison. En fait, le code ne contient aucune comparaison
entre éléments de l’entrée. Le tri par dénombrement utilise à la place les valeurs
réelles des éléments pour indexer un tableau. Le minorant V(n lg n) ne s’applique
plus quand on s’éloigne du modèle de tri par comparaison.
164
8 • Tri en temps linéaire
Le tri par dénombrement possède une propriété intéressante, à savoir la stabilité :
les nombres égaux apparaissent dans le tableau de sortie avec l’ordre qu’ils avaient
dans le tableau d’entrée. Autrement dit, une égalité éventuelle entre deux nombres
est arbitrée par la règle selon laquelle quand un nombre apparaît en premier dans le
tableau d’entrée, il apparaît aussi en premier dans le tableau de sortie. En principe, la
stabilité n’est importante que si l’élément trié est accompagné de données satellites.
Mais la stabilité présente aussi un autre intérêt : le tri par dénombrement sert souvent
de sous-routine au tri par base. Comme vous le verrez à la section suivante, la stabilité
du tri par dénombrement est un élément clé pour le bon fonctionnement du tri par
base.
Exercices
8.2.1 En s’inspirant de la figure 8.2, illustrer l’action de T RI -D ÉNOMBREMENT sur le tableau A = 6, 0, 2, 0, 1, 3, 4, 6, 1, 3, 2.
8.2.2 Démontrer que T RI -D ÉNOMBREMENT est stable.
8.2.3 Supposez que l’on réécrive ainsi l’en-tête de la boucle pour de la ligne 9 de T RI D ÉNOMBREMENT
9 pour j ← 1 à longueur[A]
Montrer que l’algorithme fonctionne encore correctement. L’algorithme modifié est-il
stable ?
8.2.4 Décrire un algorithme qui, à partir de n entiers donnés appartenant à l’intervalle 0 à
k, effectue un pré traitement après lequel il est capable de répondre en temps O(1) à toute
question du genre : combien y a-t-il d’entiers parmi les n qui appartiennent à l’intervalle
[a . . b]. Le temps de pré traitement de votre algorithme devra être Q(n + k).
8.3 TRI PAR BASE
Le tri par base est l’algorithme utilisé par les trieuses de cartes perforées, qu’on ne
trouve plus aujourd’hui que dans les musées. Une carte contient 80 colonnes ; dans
chaque colonne, on peut faire une perforation à un emplacement choisi parmi 12. La
trieuse peut être « programmée » mécaniquement pour examiner une colonne donnée
de chaque carte d’un paquet, et distribuer la carte dans un panier parmi 12, selon l’emplacement de la perforation. Un opérateur peut ensuite rassembler les cartes panier
par panier, de manière que les cartes perforées à la première position soient au-dessus
de celles perforées à la deuxième position et ainsi de suite.
Pour les chiffres décimaux, on n’utilise que 10 positions par colonne. (Les deux
autres positions servent à encoder des caractères non numériques.) Un nombre à c
8.3
Tri par base
165
chiffres s’étale donc sur c colonnes. Comme la trieuse de cartes ne peut examiner
qu’une seule colonne à la fois, le problème consistant à trier n cartes en fonction d’un
numéro à c chiffres requiert un algorithme de tri.
Intuitivement, on pourrait penser à trier les nombres selon le chiffre le plus significatif , à trier récursivement chacun des paniers résultants, puis à combiner les paquets
dans l’ordre. Malheureusement, comme les cartes de 9 des 10 paniers doivent être
mises de côté pour que l’on puisse trier chaque panier, cette procédure obligé à gérer
moult piles intermédiaires de cartes. (Voir exercice 8.3.5.)
Le tri par base résout ce problème de manière non intuitive, en commençant par
trier en fonction du chiffre le moins significatif . Les cartes sont ensuite toutes regroupées, les cartes du panier 0 précédant celles du panier 1, qui elles-mêmes précèdent
celles du panier 2, etc. Le paquet tout entier est ensuite trié en fonction du deuxième
chiffre le moins significatif, puis reconstitué d’une manière similaire. Le traitement
continue jusqu’à ce que les cartes aient été triées sur l’ensemble des c chiffres. Chose
remarquable, à ce stade les cartes sont alors complètement triées en fonction des numéros à c chiffres. Il suffit donc de c passes sur le paquet pour qu’il soit trié. La
figure 8.3 montre l’action du tri par base sur un « paquet » de sept nombres à 3
chiffres.
329
457
657
839
436
720
355
720
355
436
457
657
329
839
720
329
436
839
355
457
657
329
355
436
457
657
720
839
c Dunod – La photocopie non autorisée est un délit
Figure 8.3 Le fonctionnement du tri par base sur une liste de sept nombres de trois chiffres.
La colonne la plus à gauche est l’entrée. Les autres colonnes montrent la liste après des tris successifs, effectués en fonction des différents chiffres (pris dans l’ordre croissant de signification).
Les ombrages indiquent le chiffre sur lequel s’est fait le tri qui a produit la liste à partir de la
précédente.
Il est essentiel que les tris sur les chiffres soient stables dans cet algorithme. Le
tri effectué par une trieuse de cartes perforées est stable, mais l’opérateur doit faire
attention à ne pas modifier l’ordre des cartes quand il les sort d’un panier, bien que
toutes les cartes d’un panier aient le même chiffre dans la colonne choisie.
Dans un ordinateur classique, qui est un machine séquentielle à accès aléatoire, le
tri par base sert parfois à trier des enregistrements de données dont la clé s’étale sur
plusieurs champs. Supposons, par exemple, que l’on souhaite trier des dates selon
trois clés : année, mois et jour. On pourrait exécuter un algorithme de tri doté d’une
fonction de comparaison qui, étant données deux dates, compare les années. Si elles
concordent, il compare les mois ; si les mois concordent, il compare les jours. On peut
faire la même chose en triant les données trois fois à l’aide d’un tri stable : d’abord
selon le jour, puis selon le mois et enfin selon l’année.
8 • Tri en temps linéaire
166
Le code du tri par base ne cache aucune subtilité particulière. La procédure suivante suppose que chaque élément du tableau à n éléments A possède c chiffres, le
chiffre 1 étant le chiffre d’ordre inférieur et le chiffre c étant le chiffre d’ordre supérieur.
T RI -BASE(A, d)
1 pour i ← 1 à d
2
faire employer un tri stable pour trier tableau A selon chiffre i
Lemme 8.3 Étant donnés n nombres de d chiffres dans lesquels chaque chiffre peut
prendre k valeurs possibles, T RI -BASE trie correctement ces nombres en un temps
Q(d(n + k)).
Démonstration : On établit la validité du tri par base en raisonnant par récurrence
sur la colonne en cours de tri (voir exercice 8.3.3). L’analyse du temps d’exécution
dépend du tri stable qui sert d’algorithme de tri intermédiaire. Quand chaque chiffre
est dans l’intervalle 0 à k −1 (il peut donc prendre k valeurs possibles) et quand k n’est
pas trop grand, le tri par dénombrement est le choix évident. Chaque passe sur les n
nombres à c chiffres prend alors un temps Q(n + k). Comme il y a c passes, le temps
total du tri par base est Q(d(n + k)).
Quand d est constant et quand k = O(n), le tri par base s’exécute en temps linéaire. Plus
généralement, on dispose d’une certaine souplesse quant à la manière de décomposer
chaque clé en chiffres.
❑
Lemme 8.4 Étant donnés n nombres de b bits et un entier positif r b, T RI -BASE
trie correctement ces nombres en un temps Q((b/r)(n + 2r )).
Démonstration : Pour une valeur r b, on considère que chaque clé a d = b/r
chiffres de r bits chacun. Chaque chiffre est un entier appartenant à l’intervalle 0 à
2r − 1, de sorte que l’on peut faire du tri par dénombrement en prenant k = 2r − 1.
(Par exemple, on peut considérer un mot de 32 bits comme ayant 4 chiffres de 8 bits,
de sorte que b = 32, r = 8, k = 2r − 1 = 255 et d = b/r = 4.) Chaque passe du tri par
dénombrement prend un temps Q(n + k) = Q(n + 2r ) ; et il y a d passes, pour un temps
d’exécution total de Q(d(n + 2r )) = Q((b/r)(n + 2r )).
❑
Pour des valeurs données de n et b, on souhaite prendre la valeur de r, où r b,
qui minimise l’expression (b/r)(n + 2r ). Si b < lg n , pour toute valeur de r b,
on a (n + 2r ) = Q(n). Donc, en choisissant r = b, on obtient un temps d’exécution de
(b/b)(n + 2b ) = Q(n), qui est asymptotiquement optimal. Si b lg n , en choisissant
r = lg n , on obtient le temps optimal à un facteur constant près, ce que l’on peut
voir comme suit. Le choix r = lg n donne un temps d’exécution Q(bn/ lg n). Quand
on fait croître r au-dessus de lg n , le terme 2r du numérateur augmente plus vite que
le terme r du dénominateur ; donc, en faisant croître r au-dessus de lg n , on obtient
un temps d’exécution V(bn/ lg n). Si, à la place, on faisait décroître r au-dessous de
lg n , le terme b/r augmenterait et le terme n + 2r resterait à Q(n).
8.4
Tri par paquets
167
Le tri par base est-il préférable à un tri par comparaison comme le tri rapide ? Si
b = O(lg n), comme c’est souvent le cas et que l’on prenne r ≈ lg n, alors le temps
d’exécution du tri par base est Q(n), ce qui semble meilleur que le temps d’exécution
moyen du tri rapide qui est Q(n lg n). Les facteurs constants implicites de la notation
Q sont cependant différents. Il se peut que le tri par base fasse moins de passes que
le tri rapide sur les n clés, mais chaque passe du tri par base risque de prendre un
temps nettement plus long. Le choix de l’algorithme dépendra des caractéristiques
des implémentations, de l’ordinateur utilisé (par exemple, le tri rapide utilise souvent
les caches matériels plus efficacement que le tri par base) et des données en entrée.
En outre, la version du tri par base qui emploie le tri par dénombrement comme tri
stable intermédiaire ne trie pas sur place, contrairement à ce que font nombre de
tri par comparaison en un temps Q(n lg n). Donc, quand la mémoire principale est
une ressource critique, on préférera peut-être prendre un algorithme de tri sur place
comme le tri rapide.
Exercices
8.3.1 En s’inspirant de la figure 8.3, illustrer l’action de T RI -BASE sur la liste de mots
suivants : BAC, RUE, ROC, MUR, SUD, COQ, DUC, RAT, SAC, MER, TOT, MOU, VER,
LAC, EST, BUT.
8.3.2 Parmi les algorithmes de tri suivants, quels sont ceux qui sont stables : tri par insertion, tri par fusion, tri par tas et tri rapide ? Donner un schéma simple qui rende stable n’importe quel algorithme de tri. Combien de temps et d’espace supplémentaires votre schéma
demande-t-il ?
8.3.3 Utiliser une récurrence pour prouver que le tri par base fonctionne. À quel endroit de
la démonstration a-t-on besoin de supposer que le tri intermédiaire est stable ?
c Dunod – La photocopie non autorisée est un délit
8.3.4 Montrer comment trier n entiers de l’intervalle 0 à n2 − 1 en un temps O(n).
8.3.5 Dans le premier algorithme de tri de cartes donné dans cette section, combien de
passes faut-il exactement pour trier des nombres décimaux à c chiffres dans le cas le plus
défavorable ? Combien de piles de cartes devrait gérer un opérateur dans le cas le plus défavorable ?
8.4 TRI PAR PAQUETS
Le tri par paquets s’exécute en temps linéaire quand l’entrée suit une distribution
uniforme. À l’instar du tri par dénombrement, le tri par paquets est rapide car il fait
des hypothèses sur l’entrée. Là où le tri par dénombrement suppose que l’entrée se
compose d’entiers appartenant à un petit intervalle, le tri par paquets suppose que
8 • Tri en temps linéaire
168
l’entrée a été générée par un processus aléatoire qui distribue les éléments de manière uniforme sur l’intervalle [0, 1). (Voir la section C.2 pour la définition d’une
distribution uniforme.)
L’idée sous-jacente au tri par paquets est la suivante : on divise l’intervalle [0, 1)
en n sous-intervalles de même taille, ou paquets, puis on distribue les n nombres de
l’entrée dans les différents paquets. Comme les entrées sont distribuées de manière
uniforme sur [0, 1), on n’escompte pas qu’un paquet contienne beaucoup de nombres.
Pour produire le résultat, on se contente de trier les nombres de chaque paquet, puis
de parcourir tous les paquets, dans l’ordre, en énumérant les éléments de chacun.
Notre code du tri par paquets suppose que l’entrée est un tableau à n éléments A
et que chaque élément A[i] du tableau satisfait à 0 A[i] < 1. Le code exige un
tableau auxiliaire B[0 . . n − 1] de listes chaînées (paquets) et suppose qu’il existe un
mécanisme pour la gestion de ce genre de listes. (La section 10.2 explique comment
implémenter les opérations basiques de liste chaînée.)
T RI -PAQUETS(A)
1 n ← longueur[A]
2 pour i ← 1 à n
3
faire insérer A[i] dans liste B[ nA[i] ]
4 pour i ← 0 à n − 1
5
faire trier liste B[i] via tri par insertion
6 concaténer les listes B[0], B[1], . . . , B[n − 1] dans l’ordre
La figure 8.4 illustre le fonctionnement du tri par paquets sur un tableau de 10
nombres.
1
2
3
4
5
6
7
8
9
10
A
.78
.17
.39
.26
.72
.94
.21
.12
.23
.68
(a)
B
0
1
2
3
.12
.21
.39
.17
.23
.26
4
5
6
7
.68
.72
.78
8
9
.94
(b)
Figure 8.4 Fonctionnement de T RI -PAQUETS. (a) Le tableau en entrée A[1 . . 10]. (b) Le tableau B[0 . . 9] de listes (paquets) triées, après la ligne 5 de l’algorithme. Le paquet i contient
des valeurs appartenant à l’intervalle semi-ouvert [i/10, (i + 1)/10). Le tableau trié consiste en une
concaténation ordonnée des listes B[0], B[1], . . . , B[9].
8.4
Tri par paquets
169
Pour vérifier que cet algorithme est correct, considérons deux éléments A[i]
et A[j]. On peut supposer, sans nuire à la généralité, que A[i] A[j]. Comme
nA[i] nA[j] , l’élément A[i] est placé soit dans le même paquet que A[j], soit
dans un paquet d’indice inférieur. Si A[i] et A[j] sont placés dans le même paquet,
alors la boucle pour des lignes 4–5 les place dans le bon ordre. Si A[i] et A[j] sont
placés dans des paquets différents, alors c’est la ligne 6 qui les range dans le bon
ordre. Par conséquent, le tri par paquets fonctionne correctement.
Pour analyser le temps d’exécution, observons que toutes les lignes sauf la ligne
5 prennent un temps O(n) dans le cas le plus défavorable. Reste à faire le bilan du
temps total consommé par les n appels au tri par insertion en ligne 5.
Pour analyser le coût des appels au tri par insertion, notons ni la variable aléatoire
qui désigne le nombre d’éléments placés dans le paquet B[i]. Comme le tri par insertion tourne en temps quadratique (voir section 2.2), le temps d’exécution du tri par
paquets est
T(n) = Q(n) +
n−1
O(n2i ) .
i=0
Si l’on prend les espérances des deux côtés et que l’on utilise la linéarité de l’espérance, on a
n−1
O(n2i )
E [T(n)] = E Q(n) +
i=0
= Q(n) +
= Q(n) +
n−1
i=0
n−1
E O(n2i )
(d’après la linéarité de l’espérance)
! "
O E n2i
(d’après l’équation (C.21)) .
(8.1)
i=0
c Dunod – La photocopie non autorisée est un délit
Nous affirmons que
E n2i = 2 − 1/n
(8.2)
pour i = 0, 1, . . . , n − 1. Il n’y a rien d’étonnant à ce que chaque paquet i ait la même
valeur de E [n2i ], vu que chaque valeur du tableau d’entrée A a des chances égales de
tomber dans l’un quelconque des paquets. Pour prouver l’équation (8.2), on définit
des variables indicatrices
Xij = I {A[j] va dans le paquet i}
pour i = 0, 1, . . . , n − 1 et j = 1, 2, . . . , n. Par conséquent,
ni =
n
j=1
Xij .
8 • Tri en temps linéaire
170
Pour calculer E [n2i ], on développe le carré puis on regroupe les termes :
n
2
2
= E
Xij
E ni
= E
j=1
n n
Xij Xik
j=1 k=1

n
Xij2 +
= E

j=1
1 j n1 k n
kfij
n
E Xij2 +
=
j=1

Xij Xik 

E [Xij Xik ] ,
(8.3)
1 j n1 k n
kfij
La dernière ligne découle de la linéarité de l’espérance. On évalue les deux sommes
séparément. La variable indicatrice Xij est égale à 1 avec la probabilité 1/n, et égale
à 0 sinon ; par conséquent
1
1
E Xij2 = 1· + 0· 1 −
n
n
1
.
=
n
Quand k fi j, les variables Xij et Xik sont indépendantes ; d’où
E [Xij Xik ] = E [Xij ] E [Xik ]
1 1
·
=
n n
1
.
=
n2
En substituant ces deux valeurs attendues dans l’équation (8.3), on obtient
E
n2i
=
n
1
j=1
n
+
1 j n1 k n
kfij
1
1
+ n(n − 1)· 2
n
n
n−1
= 1+
n
1
= 2− ,
n
= n·
ce qui démontre l’équation (8.2).
1
n2
Problèmes
171
En utilisant cette espérance dans l’équation (8.1), on en conclut que le temps attendu du tri par paquet est Q(n) + n·O(2 − 1/n) = Q(n). Ainsi, l’algorithme complet
du tri par paquet s’exécute en temps moyen linéaire.
Même si l’entrée ne provient pas d’une distribution uniforme, le tri par paquet peut
encore éventuellement s’exécuter en temps linéaire. Du moment que l’entrée vérifie
la propriété selon laquelle la somme des carrés des tailles de paquet est une fonction
linéaire du nombre total d’éléments, l’équation (8.1) exprime que le tri par paquet
s’exécute en temps linéaire.
Exercices
8.4.1 En s’inspirant de la figure 8.4, illustrer l’action de T RI -PAQUETS sur le tableau
A = .79, .13, .16, .64, .39, .20, .89, .53, .71, .42.
8.4.2 Quel est le temps d’exécution, dans le cas le plus défavorable, de l’algorithme du
tri par paquet ? Quelle modification simple permettrait à l’algorithme de garder son temps
d’exécution moyen linéaire, tout en obtenant un temps d’exécution O(n lg n) pour le cas le
plus défavorable ?
8.4.3 Soit X une variable aléatoire qui est égale au nombre de « pile » dans deux jets d’une
pièce non truquée. Combien vaut E [X 2 ] ? Et E2 [X] ?
c Dunod – La photocopie non autorisée est un délit
8.4.4 Soient n points à l’intérieur du disque unité, pi = (xi , yi ), avec 0 < xi2 + y2i 1
pour i = 1, 2, . . . , n. On suppose que ces points sont distribués uniformément, c’est à dire que
la probabilité de trouver un point dans une région du disque est proportionnelle à la surface
de cette région. Concevoir un algorithme
ayant un temps moyen Q(n) qui puisse trier les n
points en fonction de leurs distances di = xi2 + y2i par rapport à l’origine. (conseil : Prendre
les tailles des paquets dans T RI -PAQUETS de façon qu’elles reflètent la distribution uniforme
des points dans le disque unité.)
8.4.5 Une fonction de distribution de probabilité P(x) d’une variable aléatoire X est définie par P(x) = Pr {X x}. Soit une liste de n variables aléatoires X1 , X2 , . . . , Xn provenant
d’une fonction P de distribution de probabilité continue qui est calculable en temps O(1).
Montrer comment trier ces nombres en temps moyen linéaire.
PROBLÈMES
8.1. Minorants pour le cas moyen d’un tri par comparaison
Dans ce problème, on va démontrer l’existence d’un minorant V(n lg n) pour le temps
d’exécution moyen d’un tri par comparaison quelconque, déterministe ou randomisé,
portant sur n entrées distinctes. On commencera par examiner un tri par comparaison
déterministe A ayant un arbre de décision TA . On supposera que toutes les permutations des entrées de A sont équiprobables.
172
8 • Tri en temps linéaire
a. On suppose que chaque feuille de TA est étiquetée avec la probabilité qu’elle a
d’être accessible à partir d’une entrée aléatoire donnée. Démontrer qu’il y a exactement n! feuilles qui sont étiquetées 1/n! et que les autres sont étiquetées 0.
b. Soit D(T) la longueur de chemin extérieur d’un arbre T, c’est à dire le cumul
des profondeurs de toutes les feuilles de T. Soit T un arbre de décision à k > 1
feuilles, et soient LT et RT les sous-arbres gauche et droite de T. Montrer que
D(T) = D(LT) + D(RT) + k.
c. Soit d(k) la valeur minimale de D(T) pour l’ensemble des arbres de décision T
à k > 1 feuilles. Montrer que d(k) = min1 i k−1 {d(i) + d(k − i) + k}. (conseil :
Considérer un arbre de décision T à k feuilles pour qui le minimum est atteint.
Soient i0 le nombre de feuilles de LT et k − i0 le nombre de feuilles de RT.)
d. Démontrer que, pour une valeur donnée de k > 1 et pour i appartenant à l’intervalle 1 i k − 1, la fonction i lg i + (k − i) lg(k − i) est minimisée en i = k/2.
En conclure que d(k) = V(k lg k).
e. Démontrer que D(TA ) = V(n! lg(n!)) et en conclure que le temps attendu pour trier
n éléments est V(n lg n).
À présent, considérons un tri par comparaison randomisé B. On peut étendre le modèle de l’arbre de décision de façon qu’il intègre la randomisation, en y incorporant
deux sortes de nœuds : des nœuds de comparaison ordinaires et des nœuds de « randomisation ». Un nœud de randomisation représente un choix aléatoire de la forme
R ANDOM(1, r) effectué par l’algorithme B ; le nœud a r enfants, dont chacun a une
probabilité égale d’être choisi lors d’une exécution de l’algorithme.
f. Montrer que, pour un tri par comparaison randomisé B quelconque, il existe un tri
par comparaison déterministe A qui n’effectue pas plus de comparaisons que B en
moyenne.
8.2. Tri sur place en temps linéaire
Supposez que l’on ait un tableau de n enregistrements de données à trier et que la
clé de chaque enregistrement ait la valeur 0 ou 1. Un algorithme de tri pour un tel
ensemble d’enregistrements pourrait posséder un certain sous-ensemble des trois caractéristiques souhaitables que voici :
1) L’algorithme s’exécute en un temps O(n).
2) L’algorithme est stable.
3) L’algorithme trie sur place, ne consommant pas plus d’un volume constant d’espace de stockage en plus du tableau original.
a. Donner un algorithme qui satisfasse aux critère 1 et 2.
b. Donner un algorithme qui satisfasse aux critère 1 et 3.
c. Donner un algorithme qui satisfasse aux critère 2 et 3.
Problèmes
173
d. L’un quelconque des algorithmes donnés dans les parties (a)–(c) peut-il servir à
trier n enregistrements dotés de clés de b bits à l’aide du tri par base en un temps
O(bn) ? Justifier la réponse.
e. Supposez que les n enregistrements aient des clés appartenant à l’intervalle 1 à k.
Montrer comment modifier le tri par dénombrement de façon que les enregistrements soient triés sur place en temps O(n + k). Vous pouvez utiliser un stockage
O(k) extérieur au tableau donné en entrée. L’algorithme est-il stable ? (Conseil :
Comment le feriez-vous pour k = 3 ?)
8.3. Tri d’éléments de longueur variable
c Dunod – La photocopie non autorisée est un délit
a. Soit un tableau d’entiers dans lequel chaque entier peut avoir un nombre de
chiffres différent, sachant que le nombre total de chiffres pour tous les entiers du
tableau est n. Montrer comment trier le tableau en temps O(n).
b. Soit un tableau de chaînes de caractères dans lequel chaque chaîne peut avoir un
nombre de caractères différent, sachant que le nombre total de caractères pour
l’ensemble des chaînes est n. Montrer comment trier le tableau en temps O(n).
(Notez que l’ordre souhaité ici est l’ordre alphabétique standard, par exemple
a < ab < b.)
8.4. Pots à eau
Vous avez n pots à eau de couleur rouge et n pots à eau de couleur bleue, tous de
formes et de tailles différentes. Tous les pots rouges ont des contenances différentes,
et c’est aussi le cas des pots bleus. En outre, pour chaque pot rouge, il existe un pot
bleu qui a la même contenance, et vice versa.
Votre travail consiste à regrouper les pots par paires, chaque paire étant faite d’un
pot rouge et d’un pot bleu de même contenance. Pour ce faire, vous pouvez procéder
ainsi : prendre une paire contenant un pot rouge et un pot bleu, remplir le pot rouge
d’eau, puis verser l’eau dans le pot bleu. Cette opération vous dira si c’est le pot rouge
ou le pot bleu qui peut contenir le plus d’eau, ou bien s’ils ont la même contenance.
Supposez qu’une telle comparaison prenne une unité de temps. Vous devez trouver un
algorithme qui fasse le minimum de comparaisons pour déterminer le regroupement
par paires. Vous ne pouvez pas comparer directement deux pots rouges, ni deux pots
bleus.
a. Décrire un algorithme déterministe qui utilise Q(n2 ) comparaisons pour regrouper
les pots par paires.
b. Prouver le minorant V(n lg n) pour le nombre de comparaisons que doit effectuer
un algorithme résolvant ce problème.
c. Donner un algorithme randomisé dont le nombre attendu de comparaisons est
O(n lg n), et prouver la validité de cette borne. Quel est le nombre de comparaisons
que fait votre algorithme dans le cas le plus défavorable ?
174
8 • Tri en temps linéaire
8.5. Tri par moyenne
Supposez que, au lieu de trier un tableau, on veuille seulement que les éléments
augmentent en moyenne. Plus précisément, on dit qu’un tableau à n éléments A est
k-trié si, pour tout i = 1, 2, . . . , n − k, on a
i+k
i+k−1
A[j]
j=i
j=i+1 A[j]
.
k
k
a. Qu’est-ce que cela signifie pour un tableau d’être 1-trié ?
b. Donner une permutation des nombres 1, 2, . . . , 10 qui soit 2-triée, mais pas triée.
c. Prouver qu’un tableau à n éléments est k-trié si et seulement si A[i] A[i + k] pour
tout i = 1, 2, . . . , n − k.
d. Donner un algorithme qui k-trie un tableau de n éléments en un temps O(n lg(n/k)).
On peut aussi démontrer un minorant pour le temps requis par la production d’un
tableau k-trié, quand k est une constante.
e. Montrer qu’un tableau k-trié de longueur n peut être trié en un temps O(n lg k).
(Conseil : Utiliser la solution de l’exercice 6.5.8. )
f. Montrer que, quand k est une constante, il faut un temps V(n lg n) pour k-trier un
tableau à n éléments. (Conseil : Utiliser la solution de la partie précédente, ainsi
que le minorant des tris par comparaison.)
8.6. Minorant pour fusion de listes triées
La fusion de deux listes triées est un problème qui revient souvent. C’est une sousroutine de T RI -F USION et la procédure de fusion de deux listes triées est donnée en
tant que F USION à la section 2.3.1. Dans ce problème, on va montrer qu’il existe un
minorant 2n − 1 pour le nombre de comparaisons requis, dans le cas le plus défavorable, par la fusion de deux listes triées ayant chacune n éléments.
On montrera d’abord un minorant de 2n − o(n) comparaisons, en utilisant un arbre
de décision.
! "
a. Montrer que, étant donnés 2n nombres, il y a 2n
n façons possibles de les diviser
en deux listes triées de n nombres chacune.
b. En employant un arbre de décision, montrer que tout algorithme qui fusionne correctement deux listes triées effectue au moins 2n − o(n) comparaisons.
On va maintenant exhiber une borne un peu plus fine de 2n − 1.
c. Montrer que, si deux éléments sont consécutifs dans l’ordre trié et qu’ils proviennent de listes opposées, alors ils sont forcément comparés.
d. Utiliser la réponse de la partie précédente pour exhiber un minorant de 2n − 1
comparaisons pour la fusion de deux listes triées.
Notes
175
NOTES
c Dunod – La photocopie non autorisée est un délit
Le modèle d’arbre de décision pour l’étude des tris par comparaison a été introduit par Ford et
Johnson [94]. L’exposé très complet Knuth sur le tri [185] présente de nombreuses variantes
du problème du tri, dont le minorant théorique sur la complexité du tri donné ici. Des minorants pour le tri basés sur des généralisations du modèle d’arbre de décision ont été étudiés
de manière exhaustive par Ben-Or [36].
Knuth attribue à H. H. Seward l’invention du tri par dénombrement en 1954, ainsi que
l’idée de combiner tri par dénombrement et tri par base. Le tri par base, dans lequel on
commence par le chiffre le moins significatif, semble être un algorithme traditionnellement
utilisé par les opérateurs des trieuses mécaniques de cartes perforées. Selon Knuth, la première référence publiée sur cette méthode est un document de 1929, où L. J. Comrie décrit
des équipements pour cartes perforées. Le tri par paquet est utilisé depuis 1956, date où l’idée
initiale fut proposée par E. J. Isaac et R. C. Singleton.
Munro et Raman [229] donnent un algorithme de tri stable qui fait O(n1+´ ) comparaisons
dans le cas le plus défavorable, où 0 < ´ 1 est une constante quelconque fixée à l’avance.
Les algorithmes de tri en temps O(n lg n) font moins de comparaisons, mais l’algorithme de
Munro et Raman ne fait que O(n) transferts de données et il trie sur place.
Le problème du tri de n entiers de b bits en temps o(n lg n) a été étudié par de nombreux
chercheurs. On a obtenu plusieurs résultats positifs, chacun d’eux faisant des hypothèses un
peu différentes sur le modèle de calcul et sur les restrictions imposées à l’algorithme. Tous
les résultats supposent que la mémoire de l’ordinateur est divisée en mots adressables de b
bits. Fredman et Willard [99] ont introduit la structure de données d’arbre de fusion, qu’ils
ont utilisée pour trier
√ n entiers en temps O(n lg n/ lg lg n). Cette borne a été ultérieurement
améliorée en O(n lg n) par Andersson [16]. Ces algorithmes exigent l’emploi de la multiplication et de plusieurs constantes précalculées. Andersson, Hagerup, Nilsson et Raman
[17] ont montré comment trier n entiers en temps O(n lg lg n) sans faire appel à la multiplication, mais leur méthode exige de l’espace de stockage qui peut être non borné par rapport
à n. Via hachage multiplicatif, l’on peut diminuer l’espace en le faisant tomber à O(n), mais
la borne O(n lg lg n) du cas le plus défavorable du temps d’exécution devient une borne de
temps moyen. En généralisant les arbres de recherche exponentiels de Andersson [16], Thorup [297] a donné un algorithme de tri en temps O(n(lg lg n)2 ) qui ne fait pas appel à la
multiplication, ni à la randomisation, et qui utilise de l’espace linéaire. En combinant ces
techniques avec des idées nouvelles, Han [137] a amélioré la borne du tri, la faisant passer
à O(n lg lg n lg lg lg n). Tous ces algorithmes sont des avancées théoriques majeures, mais ils
sont plutôt complexes et, pour l’instant, ils ne semblent pas pouvoir remplacer, en pratique,
les algorithmes de tri existants.
Chapitre 9
c Dunod – La photocopie non autorisée est un délit
Médians et rangs
Le i-ème rang d’un ensemble de n éléments est le i-ème plus petit élément. Par
exemple, le minimum d’un ensemble d’éléments est l’élément de rang 1 (i = 1),
et le maximum est l’élément de rang n (i = n). Un médian, de manière informelle,
est le « point du milieu » de l’ensemble. Si n est impair, le médian est unique et a le
rang i = (n + 1)/2. Si n est pair, il existe deux médians, de rang i = n/2 et i = n/2 + 1.
Donc, si l’on ne tient pas compte de la parité de n, les médians se trouvent aux rangs
i = (n + 1)/2 (médian inférieur) et i = (n + 1)/2 (médian supérieur). Toutefois, pour simplifier l’écriture, pour nous le terme « médian » désignera le médian
inférieur.
Ce chapitre va traiter du problème de la sélection du i-ème rang dans un ensemble
de n nombres distincts. Nous supposerons, par commodité, que l’ensemble contient
des nombres distincts, bien que presque tout ce que nous verrons puisse s’appliquer
au cas où un ensemble contient des doublons. Le problème de la sélection peut être
spécifié formellement de la façon suivante :
Entrée : Un ensemble A de n nombres (distincts) et un nombre i tel que 1 i n.
Sortie : L’élément x ∈ A qui est plus grand que i − 1 (exactement) autres éléments
de A.
Le problème de la sélection peut être résolu en temps O(n lg n) ; en effet, on peut trier
les nombres à l’aide du tri par tas ou du tri par fusion, puis se contenter d’indexer le
i-ème élément du tableau de sortie. Il existe cependant des algorithmes plus rapides.
À la section 9.1, on examinera le problème de la sélection du minimum et du
maximum d’un ensemble d’éléments. Le problème général de la sélection est plus
178
9 • Médians et rangs
intéressant, et il sera étudié dans les deux sections suivantes. La section 9.2 analyse
un algorithme pratique dont le temps d’exécution possède une borne O(n) dans le cas
moyen. La section 9.3 propose un algorithme d’intérêt plus théorique dont le temps
d’exécution est O(n) dans le cas le plus défavorable.
9.1 MINIMUM ET MAXIMUM
Combien de comparaisons faut-il effectuer pour déterminer le minimum d’un ensemble de n éléments ? On peut facilement obtenir un majorant de n − 1 comparaisons : on examine un par un chaque élément de l’ensemble et l’on mémorise le plus
petit élément provisoire. Dans la procédure suivante, on suppose que l’ensemble est
le tableau A, avec longueur[A] = n.
M INIMUM(A)
1 min ← A[1]
2 pour i ← 2 à longueur[A]
3
faire si min > A[i]
4
alors min ← A[i]
5 retourner min
On peut, bien sûr, trouver le maximum avec n − 1 comparaisons également.
Est-il possible de faire mieux ? Oui, car on peut obtenir un minorant de n − 1
comparaisons pour le problème consistant à déterminer le minimum. On peut imaginer un algorithme qui détermine le minimum sous la forme d’un tournoi. Chaque
comparaison est un match, remporté par le plus petit élément de la paire concernée.
Il faut bien comprendre ici que tout élément, hormis le vainqueur, perdra au moins
un match. Il faudra donc n − 1 comparaisons pour déterminer le minimum, et l’algorithme M INIMUM est optimal pour ce qui concerne le nombre de comparaisons
effectuées.
a) Minimum et maximum simultanément
Dans certaines applications, on doit trouver en même temps le minimum et le maximum d’un ensemble de n éléments. Par exemple, un programme graphique peut avoir
besoin de changer l’échelle d’un ensemble de données (x, y) pour le faire tenir sur un
écran rectangulaire ou sur tout autre périphérique de sortie graphique. Pour cela, le
programme doit commencer par déterminer le minimum et le maximum de chaque
coordonnée.
Il n’est pas très difficile de construire un algorithme capable de trouver à la fois le
minimum et le maximum de n éléments avec un nombre de comparaisons Q(n), ce
qui est asymptotiquement optimal. Il suffit de trouver indépendamment le minimum
et le maximum en utilisant n − 1 comparaisons pour chacun, ce qui donnera un total
de 2n − 2 comparaisons.
9.2
Sélection en temps moyen linéaire
179
En fait, il suffit de 3 n/2 comparaisons au plus pour trouver à la fois le minimum
et le maximum. La stratégie consiste à mémoriser les éléments minimal et maximal
provisoires. Au lieu de traiter chaque élément de l’entrée en le comparant avec les
minimum et maximum courants, ce qui ferait deux comparaisons par élément, on
traite les éléments par paires. On commence par comparer entre eux les éléments de
chaque paire, puis l’on compare le plus petit des deux avec le minimum provisoire
et le plus grand des deux avec le maximum provisoire, ce qui ne fait plus que trois
comparaisons par paire d’éléments.
La définition des valeurs initiales des minimum et maximum provisoires varie selon que n est pair ou non. Si n est impair, on prend comme minimum et maximum
la valeur du premier élément, puis on traite le reste des éléments par paires. Si n est
pair, on fait 1 comparaison sur les deux premiers éléments pour déterminer les valeurs initiales du minimum et du maximum, puis on traite le reste des éléments par
paires comme dans le cas n impair.
Analysons le nombre total de comparaisons. Si n est impair, alors on fait 3 n/2
comparaisons. Si n est pair, on fait 1 comparaison initiale, suivie de 3(n − 2)/2 comparaisons, pour un total de 3n/2 − 2. Donc, dans l’un ou l’autre cas, le nombre total
de comparaisons est au plus de 3 n/2 .
Exercices
9.1.1 Montrer que le deuxième plus petit élément parmi n peut être trouvé avec n + lg n − 2
comparaisons dans le cas le plus défavorable. (Conseil : Trouver aussi le plus petit élément.)
9.1.2 Montrer qu’il faut 3n/2 − 2 comparaisons, dans le cas le plus défavorable, pour
trouver à la fois le maximum et le minimum de n nombres. (Conseil : Compter combien il y
a de nombres qui sont potentiellement susceptibles d’être le maximum ou le minimum, puis
étudier la façon dont une comparaison affecte ces quantités.)
c Dunod – La photocopie non autorisée est un délit
9.2 SÉLECTION EN TEMPS MOYEN LINÉAIRE
Le problème général de la sélection s’avère plus complexe que le simple problème
consistant à trouver un minimum bien que, étonnamment, le temps d’exécution
asymptotique soit identique : Q(n). Dans cette section, nous présenterons un
algorithme diviser-pour-régner pour le problème de la sélection. L’algorithme
S ÉLECTION -R ANDOMISÉE s’inspire de l’algorithme de tri rapide du chapitre 7.
Comme pour le tri rapide, l’idée est de partitionner récursivement le tableau d’entrée.
Mais contrairement au tri rapide, qui traite récursivement les deux côtés de la partition, S ÉLECTION -R ANDOMISÉE ne s’occupe que d’un seul côté. Cette différence se
reflète dans l’analyse : alors que le tri rapide possède un temps d’exécution attendu
Q(n lg n), celui de S ÉLECTION -R ANDOMISÉE est Q(n).
180
9 • Médians et rangs
S ÉLECTION -R ANDOMISÉE utilise la procédure PARTITION -R ANDOMISÉE de la
section 7.3. C’est donc, comme T RI -R APIDE -R ANDOMISÉ, un algorithme randomisé, puisque son comportement est déterminé en partie par le résultat d’un générateur de nombres aléatoires. Le code suivant pour S ÉLECTION -R ANDOMISÉE fournit
le i-ème plus petit élément du tableau A[p . . r].
S ÉLECTION -R ANDOMISÉE(A, p, r, i)
1 si p = r
2
alors retourner A[p]
3 q ← PARTITION -R ANDOMISÉE(A, p, r)
4 k ←q−p+1
5 si i = k
la valeur du pivot est la réponse
6
alors retourner A[q]
7 sinon si i < k
8
alors retourner S ÉLECTION -R ANDOMISÉE(A, p, q − 1, i)
9 sinon retourner S ÉLECTION -R ANDOMISÉE(A, q + 1, r, i − k)
Après l’exécution de PARTITION -R ANDOMISÉE en ligne 3 de l’algorithme, le
tableau A[p . . r] est partitionné en deux sous-tableaux (éventuellement vides)
A[p . . q − 1] et A[q + 1 . . r] tels que chaque élément de A[p . . q − 1] soit inférieur
ou égal à A[q] qui est, lui-même, inférieur à chaque élément de A[q + 1 . . r]. Comme
dans le tri rapide, on dit de A[q] que c’est l’élément pivot. La ligne 4 de l’algorithme
calcule le nombre k d’éléments du sous-tableau A[p . . q], c’est-à-dire le nombre
d’éléments de la partie inférieure de la partition, plus un pour le pivot. La ligne 5
teste ensuite si A[q] est le i-ème plus petit élément. Si tel est le cas, la procédure
retourne A[q]. Autrement, l’algorithme détermine si le i-ème plus petit élément se
trouve dans A[p . . q − 1] ou dans A[q + 1 . . r]. Si i < k, c’est que l’élément désiré se
trouve dans la région inférieure de la partition ; il est alors sélectionné récursivement
dans le sous-tableau (ligne 8). Si i > k, c’est que l’élément souhaité se trouve
dans la région supérieure de la partition. Comme on connaît déjà k valeurs qui sont
inférieures au i-ème plus petit élément de A[p . . r], à savoir les éléments de A[p . . q],
l’élément souhaité est le (i−k)-ème plus petit élément de A[q+1 . . r], élément qui est
sélectionné récursivement en ligne 9. Le code semble autoriser les appels récursifs
à des sous-tableaux de 0 élément, mais l’exercice 9.2.1 vous demandera de montrer
qu’un tel cas ne peut pas se produire.
Le temps d’exécution de S ÉLECTION -R ANDOMISÉE, dans le cas le plus défavorable, est Q(n2 ), même pour trouver le minimum. En effet, si l’on a beaucoup de
malchance, il se peut que le partitionnement se fasse systématiquement autour du
plus grand élément restant, et le partitionnement prend un temps Q(n). Cela dit, l’algorithme fonctionne bien dans le cas moyen ; et comme il est randomisé, il n’y a
aucune entrée particulière qui entraîne systématiquement le cas le plus défavorable.
Le temps requis par S ÉLECTION -R ANDOMISÉE sur un tableau A[p . . r] de n éléments est une variable aléatoire que nous noterons T(n). Nous allons maintenant
9.2
Sélection en temps moyen linéaire
181
exhiber un majorant pour E [T(n)]. PARTITION -R ANDOMISÉE a des chances équiprobables de retourner n’importe quel élément comme étant le pivot. Donc, pour tout
k tel que 1 k n, le sous-tableau A[p . . q] a k éléments (tous inférieurs ou égaux
au pivot) avec la probabilité 1/n. Pour k = 1, 2, . . . , n, on définit des variables indicatrices Xk par
Xk = I {le sous-tableau A[p . . q] a exactement k éléments} ,
On a donc
E [Xk ] = 1/n .
(9.1)
Quand on appelle S ÉLECTION -R ANDOMISÉE et que l’on choisit A[q] comme pivot, on ne sait pas a priori si l’on va avoir toute de suite la bonne réponse, ou si l’on va
devoir appeler récursivement la procédure sur le sous-tableau A[p . . q − 1], ou encore
si on va devoir l’appeler récursivement sur le sous-tableau A[q+1 . . r]. Cette décision
dépend de l’endroit où le i-ème plus petit élément se trouve par rapport à A[q]. Si l’on
suppose T(n) monotone croissante, on peut borner le temps requis par l’appel récursif
par le temps qu’exige l’appel récursif sur la plus grande entrée possible. Autrement
dit, on suppose, pour obtenir un majorant, que le i-ème élément est toujours dans la
région de la partition qui contient le plus grand nombre d’éléments. Pour un appel
donné de S ÉLECTION -R ANDOMISÉE, la variable indicatrice Xk a la valeur 1 pour
une et une seule valeur de k, et elle vaut 0 pour tous les autres k. Quand Xk = 1, les
deux sous-tableaux sur lesquels on serait susceptible de faire de la récursivité ont des
tailles k − 1 et n − k. D’où la récurrence suivante
T(n) =
n
Xk ·(T(max(k − 1, n − k)) + O(n))
k=1
n
(Xk ·T(max(k − 1, n − k)) + O(n) .
c Dunod – La photocopie non autorisée est un délit
k=1
En prenant les espérances, on obtient
E [T(n)]
n
Xk ·T(max(k − 1, n − k)) + O(n)
E
k=1
=
=
=
n
k=1
n
k=1
n
k=1
E [Xk ·T(max(k − 1, n − k))] + O(n)
(d’après linéarité de l’espérance)
E [Xk ] ·E [T(max(k − 1, n − k))] + O(n) (d’après équation (C.23))
1
·E [T(max(k − 1, n − k))] + O(n)
n
(d’après équation (9.1)) .
9 • Médians et rangs
182
Pour pouvoir appliquer l’équation C.23, on s’appuie sur le fait que Xk et
T(max(k − 1, n − k)) sont des variables aléatoires indépendantes. L’exercice 9.2.2
vous demandera de justifier cette assertion.
Considérons l’expression max(k − 1, n − k). Nous avons
k − 1 si k > n/2 ,
max(k − 1, n − k) =
n − k si k n/2 .
Si n est pair, chaque terme de T(n/2) à T(n − 1) apparaît exactement deux fois dans
la sommation ; si n est impair, tous ces termes apparaissent deux fois et T( n/2 )
apparaît une fois. On a donc
E [T(n)] n−1
2 E [T(k)] + O(n) .
n
k=n/2
On va résoudre la récurrence par la méthode de substitution. Supposons que
T(n) cn pour une certaine constante c qui satisfait à la condition initiale de la
récurrence. On suppose que T(n) = O(1) pour n inférieur à une certaine constante ;
on exhibera cette constante ultérieurement. On va aussi choisir une constant a telle
que la fonction décrite par le terme O(n) ci-dessus (qui décrit la composante non récursive du temps d’exécution de l’algorithme) soit majorée par an pour tout n > 0.
En utilisant cette hypothèse de récurrence, on obtient
E [T(n)] =
n−1
2 ck + an
n
k=n/2


n/2−1
n−1
2c 
k−
k + an
n
k=1
=
=
k=1
2c
n
2c
n
(n − 1)n ( n/2 − 1) n/2
−
+ an
2
2
(n − 1)n (n/2 − 2)(n/2 − 1)
−
+ an
2
2
2c
n
n2 − n n2 /4 − 3n/2 + 2
−
2
2
3n2 n
+ − 2 + an
4
2
3n 1 2 + −
+ an
= c
4 2 n
3cn c
+ + an
4 2
cn c
− − an .
= cn −
4
2
=
c
n
+ an
9.3
Sélection en temps linéaire dans le cas le plus défavorable
183
Pour terminer la démonstration, il faut montrer que, pour n suffisamment grand,
cette dernière expression est au plus égale à cn ou, ce qui revient au même, que
cn/4 − c/2 − an 0. Si l’on ajoute c/2 aux deux membres et que l’on met n en
facteur, on obtient n(c/4 − a) c/2. Si nous choisissons la constante c de telle façon
que c/4 − a > 0, c’est-à-dire si c > 4a, alors nous pouvons diviser les deux côtés
par c/4 − a, ce qui donne
n
2c
c/2
=
.
c/4 − a c − 4a
Par conséquent, si l’on suppose que T(n) = O(1) pour n < 2c/(c − 4a), on a
T(n) = O(n). Nous en concluons que la sélection d’un rang quelconque, en particulier
le médian, prend en moyenne un temps linéaire.
Exercices
9.2.1 Montrer que, dans S ÉLECTION -R ANDOMISÉE, il n’y a jamais d’appel récursif sur un
tableau de longueur 0.
9.2.2 Prouver que la variable indicatrice Xk et la valeur T(max(k − 1, n − k)) sont indépendantes.
9.2.3 Écrire une version itérative de S ÉLECTION -R ANDOMISÉE.
9.2.4 On utilise S ÉLECTION -R ANDOMISÉE pour sélectionner l’élément minimum du tableau A = 3, 2, 9, 0, 7, 5, 4, 8, 6, 1. Décrire une séquence de partitions qui produise les
performances du cas le plus défavorable pour S ÉLECTION -R ANDOMISÉE.
c Dunod – La photocopie non autorisée est un délit
9.3 SÉLECTION EN TEMPS LINÉAIRE DANS LE CAS LE PLUS
DÉFAVORABLE
Nous allons maintenant examiner un algorithme de sélection dont le temps d’exécution est O(n) dans le cas le plus défavorable. À l’instar de S ÉLECTION R ANDOMISÉE, l’algorithme S ÉLECTION trouve l’élément souhaité en partitionnant
récursivement le tableau d’entrée. Toutefois, l’idée sous-jacente à cet algorithme est
de garantir un bon découpage lorsque le tableau est partitionné. S ÉLECTION utilise l’algorithme de partitionnement déterministe PARTITION du tri rapide (voir section 7.1), modifié de façon à prendre comme paramètre d’entrée l’élément autour
duquel se fait le partitionnement.
L’algorithme S ÉLECTION détermine le i-ème plus petit élément d’un tableau de
n > 1 éléments en exécutant les étapes suivantes. (Si n = 1, S ÉLECTION se contente
de retourner l’unique valeur d’entrée comme i-ème plus petit élément.)
9 • Médians et rangs
184
1) On divise les n éléments du tableau en n/5 groupes de 5 éléments chacun, plus
éventuellement le groupe constitué des n mod 5 éléments restants.
2) On trouve le médian de chacun des n/5 groupes en commençant par trier par
insertion les éléments du groupe (il y en a 5 au plus), puis en prenant le médian de
la liste triée des éléments du groupe.
3) On utilise S ÉLECTION de façon récursive pour trouver le médian x des n/5
médians trouvés à l’étape 2. (S’il y a un nombre pair de médians, de par notre
convention x sera le médian inférieur.)
4) On partitionne le tableau d’entrée autour du médian des médians x à l’aide de la
version modifiée de PARTITION. Soit k le nombre d’éléments de la région inférieure de la partition, augmenté de un ; ainsi, x est le k-ème plus petit élément et il
y a n − k éléments dans la région haute de la partition.
5) Si i = k, alors on retourne x. Sinon, on utilise S ÉLECTION récursivement pour trouver le i-ème plus petit élément de la région inférieure si i < k, ou le (i − k)-ème
plus petit élément de la région supérieure si i > k.
x
Figure 9.1 Analyse de l’algorithme S ÉLECTION. Les n éléments sont représentés par de petits
cercles, et chaque groupe occupe une colonne. Les médians des groupes sont coloriés en blanc,
et le médian des médians a l’étiquette x. (Pour un nombre pair d’éléments, il s’agit du médian
inférieur). Les flèches partent des plus grands éléments vers les plus petits ; on peut voir ainsi que,
dans chaque groupe plein (5 éléments) situé à droite de x, il y a 3 éléments qui sont supérieurs à
x, et que, dans chaque groupe situé à gauche de x, il y a 3 éléments qui sont inférieurs à x. Les
éléments plus grands que x sont sur fond gris.
Pour analyser le temps d’exécution de S ÉLECTION, commençons par minorer le
nombre d’éléments qui sont supérieurs à l’élément de partitionnement x. La figure 9.1
illustre le processus. La moitié au moins des médians trouvés à l’étape 2 sont supérieurs (1) au médian des médians x. Donc, la moitié au moins des n/5 groupes
contiennent chacun 3 éléments supérieurs à x, exceptions faites du groupe contenant
(1) Comme nous avions supposé que les nombres étaient distincts, nous pouvons dire « supérieur à » et
« inférieur à » sans nous préoccuper d’éventuelles égalités.
9.3
Sélection en temps linéaire dans le cas le plus défavorable
185
moins de 5 éléments (ce groupe n’existe que si 5 ne divise pas n) et du groupe formé
de x. Si l’on ne tient pas compte de ces deux groupes, il s’ensuit que le nombre d’éléments supérieurs à x est au moins
1 n 3n
−2 −6.
3
2 5
10
De même, le nombre d’éléments inférieurs à x est au moins 3n/10 − 6. Donc, dans le
cas le plus défavorable, S ÉLECTION est appelé récursivement sur au plus 7n/10 + 6
éléments à l’étape 5.
On peut à présent établir une récurrence pour le temps d’exécution T(n) de l’algorithme S ÉLECTION dans le cas le plus défavorable. Les étapes 1, 2 et 4 s’exécutent
en un temps O(n). (L’étape 2 se compose de O(n) appels au tri par insertion sur
des ensembles de taille O(1).) L’étape 3 nécessite un temps T(n/5), et l’étape 5 un
temps au plus égal à T(7n/10 + 6), en supposant que T est monotone croissante. Nous
ferons l’hypothèse, a priori gratuite, que toute entrée de moins de 140 éléments prend
un temps O(1) ; nous verrons bientôt d’où sort cette constante 140. Nous obtenons
donc la récurrence
Q(1)
si n 140 ,
T(n) T(n/5) + T(7n/10 + 6) + O(n) si n > 140 .
c Dunod – La photocopie non autorisée est un délit
Nous allons montrer, via substitution, que le temps d’exécution est linéaire. Plus précisément, nous allons montrer que T(n) cn pour une constante c choisie suffisamment grande et pour tout n > 0. Commençons par supposer que T(n) cn pour
une constante c choisie suffisamment grande et pour tout n 140 ; cette hypothèse
est vraie dès que c est suffisamment grande. Choisissons également une constante a
telle que la fonction décrite par le terme O(n) ci-dessus (qui décrit la composante non
récursive du temps d’exécution de l’algorithme) soit majorée par an pour tout n > 0.
En substituant cette hypothèse de récurrence dans le côté droit de la récurrence, nous
obtenons
T(n) =
=
c n/5 + c(7n/10 + 6) + an
cn/5 + c + 7cn/10 + 6c + an
9cn/10 + 7c + an
cn + (−cn/10 + 7c + an) ,
qui est au plus égale à cn si
−cn/10 + 7c + an 0 .
(9.2)
L’inégalité (9.2) est équivalente à l’inégalité c 10a(n/(n − 70)) quand n > 70.
Comme on suppose n 140, on a n/(n − 70) 2 ; donc, en prenant c 20a, on
satisfait à l’inégalité (9.2). (Notez qu’il n’y a rien de spécial concernant la constante
140 ; on pourrait la remplacer par n’importe quel entier strictement supérieur à 70 et
choisir ensuite c en conséquence.) Le temps d’exécution du cas le plus défavorable
de S ÉLECTION est donc linéaire.
186
9 • Médians et rangs
Comme dans un tri par comparaison (voir section 8.1), S ÉLECTION et
S ÉLECTION -R ANDOMISÉE déterminent des informations sur l’ordre relatif des éléments à l’aide uniquement de comparaisons entre les éléments. Rappelez-vous (chapitre 8) que le tri exige un temps V(n lg n) dans le modèle de comparaison, même
pour le cas moyen (voir problème 8.1). Les algorithmes de tri en temps linéaire vus
au chapitre 8 font des hypothèses sur l’entrée. En comparaison, les algorithmes de
sélection en temps linéaire vus dans ce chapitre n’exigent pas que l’on fasse des
hypothèses sur l’entrée. Ils ne sont pas concernés par le minorant V(n lg n), car ils
arrivent à sélectionner sans faire de tri.
Ainsi, le temps d’exécution est linéaire parce que ces algorithmes ne trient pas ;
ce comportement temporel linéaire ne résulte pas d’hypothèses faites sur l’entrée,
comme c’était le cas avec les algorithmes de tri du chapitre 8. Le tri exige un temps
V(n lg n) dans le modèle du tri par comparaison, et ce même pour le cas moyen. Par
conséquent, la méthode de sélection basée sur le tri et l’indexation, signalée en début
de chapitre, est asymptotiquement inefficace.
Exercices
9.3.1 Dans l’algorithme S ÉLECTION, les éléments sont répartis par groupes de 5. L’algorithme fonctionnera-t-il en temps linéaire s’ils sont répartis par groupes de 7 ? Montrer que
S ÉLECTION ne s’exécutera pas en temps linéaire si l’on utilise des groupes de 3 ?
9.3.2 Analyser S ÉLECTION pour montrer que, si n 140, alors n/4 éléments au moins
sont supérieurs au médian des médians x et n/4 éléments au moins sont inférieurs à x.
9.3.3 Montrer comment améliorer le tri rapide en le faisant s’exécuter en O(n lg n) dans le
cas le plus défavorable.
9.3.4 On suppose qu’un algorithme utilise uniquement des comparaisons pour trouver le
i-ème plus petit élément d’un ensemble à n éléments. Montrer qu’il peut également trouver
les i − 1 plus petits éléments et les n − i plus grands éléments sans effectuer de comparaison
supplémentaire.
9.3.5 Supposez que vous disposiez d’une sous-routine du genre « boîte noire » pour le calcul
en temps linéaire du médian dans le cas le plus défavorable. Donner un algorithme simple à
temps linéaire qui résolve le problème de la sélection pour un rang arbitraire.
9.3.6 Le k-ième quantile d’un ensemble à n éléments est l’ensemble des k − 1 rangs qui
divisent l’ensemble trié en k sous-ensembles de taille égale (et supérieure à 1). Donner un
algorithme en O(n lg k) pour énumérer le k-ième quantile d’un ensemble.
9.3.7 Décrire un algorithme en O(n) qui, étant donnés un ensemble S de n nombres distincts
et un entier positif k n, détermine les k nombres de S qui sont les plus proches du médian
de S.
Problèmes
187
9.3.8 Soient X[1 . . n] et Y[1 . . n] deux tableaux, chacun contenant n nombres déjà triés.
Donner un algorithme en O(lg n) pour trouver le médian des 2n éléments présents dans les
tableaux X et Y.
Figure 9.2 On veut trouver, pour l’oléoduc Est-Ouest, un emplacement qui minimise la longueur
totale des raccordements Nord-Sud.
c Dunod – La photocopie non autorisée est un délit
9.3.9 L’inspecteur Derrick est consultant pour une compagnie pétrolière, qui projette de
construire un grand oléoduc d’Est en Ouest à travers un champ pétrolier de n puits. Chaque
puit devra être relié à l’oléoduc principal par un chemin minimal (Nord ou Sud), comme
le montre la figure 9.2. Connaissant les coordonnées x et y des puits, comment l’inspecteur
trouvera-t-il l’emplacement optimal (celui qui minimisera la longueur totale des raccordements aux puits) de l’oléoduc principal ? Montrer que cet emplacement peut être déterminé
en temps linéaire.
PROBLÈMES
9.1. Les i plus grands nombres en ordre trié
Étant donné un ensemble de n nombres, on souhaite trouver les i plus grands dans
l’ordre trié, à l’aide d’un algorithme à base de comparaisons. Trouver l’algorithme
qui implémente chacune des méthodes suivantes avec le meilleur temps d’exécution
asymptotique pour le cas le plus défavorable, et analyser les temps d’exécution de
algorithmes en fonction de n et i.
9 • Médians et rangs
188
a. Trier les nombres, puis énumérer les i plus grands.
b. Construire une file de priorités max à partir des nombres donnés, puis appeler
E XTRAIRE -M AX i fois.
c. Utiliser un algorithme de sélection pour trouver le ième plus grand nombre, partitionner autour de ce nombre, puis trier les i plus grands nombres.
9.2. Médian pondéré
Pour n éléments distincts x1 , x2 , . . . , xn avec des poids positifs p1 , p2 , . . . , pn tels que
n
i=1 pi = 1, le médian pondéré (inférieur) est l’élément xk satisfaisant à
1
pi <
2
x <x
i
et
xi >xk
k
pi 1
.
2
a. Démontrer que le médian de x1 , x2 , . . . , xn est le médian pondéré des xi affectés
des poids pi = 1/n pour i = 1, 2, . . . , n.
b. Montrer comment calculer le médian pondéré de n éléments en temps O(n lg n)
dans le cas le plus défavorable, en utilisant un tri.
c. Montrer comment calculer le médian pondéré en temps Q(n) dans le cas le plus
défavorable, à l’aide d’un algorithme à temps linéaire de recherche du médian, tel
le S ÉLECTION de la section 9.3.
Le problème de l’emplacement du bureau de poste est défini comme suit. Soient
n points c1 , c2 , . . . , cn et leurs poids associés p1 , p2 , . . . , pn . On souhaite trouver
un point c (pas nécessairement parmi les points d’entrée) qui minimise la somme
n
i=1 pi d(c, ci ), où d(a, b) est la distance entre les points a et b.
d. Démontrer que le médian pondéré est une solution optimale pour le problème de
l’emplacement du bureau de poste à 1 dimension, dans lequel les points sont simplement des nombres réels et la distance entre les points a et b est d(a, b) = |a − b|.
e. Trouver la solution optimale pour le problème de l’emplacement du bureau de
poste à 2 dimensions, dans lequel les points sont des paires (x, y) de coordonnées
et la distance entre les points a = (x1 , y1 ) et b = (x2 , y2 ) est la distance de Manhattan : d(a, b) = |x1 − x2 | + |y1 − y2 |.
9.3. Petits rangs
On a vu que le nombre T(n) de comparaisons faites par S ÉLECTION, dans le cas
le plus défavorable, pour sélectionner le i-ème rang parmi n nombres satisfaisait à
T(n) = Q(n) ; mais la constante implicite à la notation Q est plutôt grande. Quand
i est petit par rapport à n, on peut implémenter une procédure différente qui utilise
Notes
189
S ÉLECTION comme sous-programme mais qui effectue moins de comparaisons dans
le cas le plus défavorable.
a. Décrire un algorithme qui utilise Ui (n) comparaisons pour trouver le i-ème plus
petit élément parmi les n, où
T(n)
si i n/2 ,
Ui (n) =
n/2 + Ui (n/2) + T(2i) sinon .
(Conseil : Commencer par faire n/2 comparaisons deux à deux disjointes, puis
continuer récursivement sur l’ensemble contenant le plus petit élément de chaque
paire.)
b. Montrer que, si i < n/2, alors Ui (n) = n + O(T(2i) lg(n/i)).
c. Montrer que, si i est une constante inférieure à n/2, alors Ui (n) = n + O(lg n).
d. Montrer que, si i = n/k pour k 2, alors Ui (n) = n + O(T(2n/k) lg k).
NOTES
L’algorithme en temps linéaire de recherche du médian dans le cas le plus défavorable a été
inventé par Blum, Floyd, Pratt, Rivest et Tarjan [43]. La version en temps moyen rapide est
due à Hoare [146]. Floyd et Rivest [92] ont développé une version améliorée du temps moyen
qui partitionne autour d’un élément sélectionné récursivement dans un petit échantillon d’éléments.
c Dunod – La photocopie non autorisée est un délit
On ne sait pas encore exactement combien il faut de comparaisons pour déterminer le
médian. Un minorant de 2n comparaisons pour la recherche du médian a été exhibé par Bent
et John [38]. Un majorant de 3n a été donné par Schonhage, Paterson et Pippenger [265]. Dor
et Zwick[79] ont amélioré ces deux bornes ; leur majorant est légèrement inférieur à 2, 95n
et leur minorant est légèrement supérieur à 2n. Paterson [239] décrit ces résultats, ainsi que
d’autres travaux afférents.
PARTIE 3
c Dunod – La photocopie non autorisée est un délit
STRUCTURES DE DONNÉES
La notion d’ensemble est aussi fondamentale pour l’informatique que pour les mathématiques. Alors que les ensembles mathématiques sont stables, ceux manipulés par
les algorithmes peuvent croître, diminuer, ou subir d’autres modifications au cours du
temps. On dit de ces ensembles qu’ils sont dynamiques. Les cinq prochains chapitres
présentent quelques techniques élémentaires permettant de représenter des ensembles
dynamiques finis et de les manipuler sur un ordinateur.
Les types d’opération à effectuer sur les ensembles peuvent varier d’un algorithme
à l’autre. Par exemple, de nombreux algorithmes se contentent d’insérer, de supprimer ou de tester l’appartenance. Un ensemble dynamique qui reconnaît ces opérations est appelé dictionnaire. D’autres algorithmes nécessitent des opérations plus
complexes. Par exemple, les files de priorités min, présentées au chapitre 6 dans le
contexte de la structure de données tas, permettent de faire des opérations d’insertion
et d’extraction du plus petit élément d’un ensemble. La meilleure façon d’implémenter un ensemble dynamique dépend des opérations qu’il devra reconnaître.
a) Éléments d’un ensemble dynamique
Dans une implémentation classique d’un ensemble dynamique, chaque élément est
représenté par un objet dont les champs peuvent être examinés et manipulés à l’aide
d’un pointeur vers l’objet. (La section 10.3 étudie l’implémentation des objets et
des pointeurs dans les environnements de programmation qui ne les proposent pas
comme types de données de base.) Certains types d’ensembles dynamiques supposent
192
Partie 3
que l’un des champs de l’objet contient une clé servant d’identifiant. Si les clés sont
toutes différentes, on peut considérer l’ensemble dynamique comme un ensemble
de valeurs de clés. L’objet peut contenir des données satellites, rangées dans d’autres
champs de l’objet mais n’intervenant pas dans l’implémentation de l’ensemble. L’objet peut aussi avoir des champs qui sont manipulés par des opérations ensemblistes ;
ces champs peuvent contenir des données ou des pointeurs vers d’autres objets de
l’ensemble.
Certains ensembles dynamiques présupposent que les clés sont construites à partir
d’un ensemble totalement ordonné, comme celui des nombres réels, ou celui de tous
les mots classés dans l’ordre alphabétique habituel. (Un ensemble totalement ordonné
vérifie la propriété de « trichotomie », définie à la page 47.) Un ordre total permet
de définir le plus petit élément d’un ensemble, par exemple, ou bien de parler du
prochain élément qui est plus grand qu’un élément donné de l’ensemble.
b) Opérations sur les ensembles dynamiques
Les opérations sur un ensemble dynamique peuvent être regroupées en deux catégories : les requêtes qui se contentent de retourner des informations concernant l’ensemble, et les opérations de modification qui modifient l’ensemble. Voici une liste
des opérations classiques. L’implémentation d’une application particulière ne fera
appel en général qu’à une petite partie de ces opérations.
R ECHERCHER(S, k) : Une requête qui, étant donnés un ensemble S et une valeur de
clé k, retourne un pointeur x sur un élément de S tel que clé[x] = k, ou NIL si
l’élément en question n’appartient pas à S.
I NSERTION(S, x) : Une opération de modification qui ajoute à l’ensemble S l’élément
pointé par x. On suppose en général que tous les champs de l’élément x requis par
la définition de l’ensemble ont déjà été initialisés.
S UPPRESSION(S, x) : Une opération de modification qui, étant donné un pointeur x
vers un élément de l’ensemble S, élimine x de S. (Remarquez que cette opération
utilise un pointeur vers un élément x, et non une valeur de clé.)
M INIMUM(S) : Une requête sur un ensemble S totalement ordonné qui retourne l’élément de S ayant la plus petite clé.
M AXIMUM(S) : Une requête sur un ensemble S totalement ordonné qui retourne
l’élément de S ayant la plus grande clé.
S UCCESSEUR(S, x) : Un requête qui, étant donné un élément x dont la clé appartient
à un ensemble S totalement ordonné, retourne le prochain élément de S qui est
plus grand que x, ou NIL si x est l’élément maximal.
P RÉDÉCESSEUR(S, x) : Une requête qui, étant donné un élément x dont la clé appartient à un ensemble S totalement ordonné, retourne le prochain élément de S qui
est plus petit que x, ou NIL si x est l’élément minimal.
Structures de données
193
Les requêtes S UCCESSEUR et P RÉDÉCESSEUR sont souvent étendues à des ensembles ayant des clés non-distinctes. Pour un ensemble à n clés, l’hypothèse habituelle est qu’un appel à M INIMUM suivi de n − 1 appels à S UCCESSEUR énumère les
éléments de l’ensemble dans l’ordre trié.
Le temps nécessaire à l’exécution d’une opération d’ensemble se mesure généralement en fonction de la taille de l’ensemble passé en argument à l’opération. Ainsi,
le chapitre 13 décrit une structure de données avec laquelle toutes les opérations
susmentionnées se font en O(lg n) sur un ensemble de taille n.
c) Aperçu de la partie 3
Les chapitres 10–14 présenteront plusieurs structures de données qu’on peut utiliser
pour implémenter des ensembles dynamiques ; nombre d’entre elles seront utilisées
plus tard pour construire des algorithmes efficaces pour de nombreux problèmes. Une
autre structure de données importante, le tas, a déjà été présentée au chapitre 6.
Le chapitre 10 présentera les manipulations essentielles portant sur des structures
de données simples comme les piles, les files, les listes chaînées et les arbres enracinés. Il montre également comment implémenter objets et pointeurs dans les environnements de programmation qui ne les proposent pas comme structures de base.
Un bonne partie de ces notions sont certainement familières à ceux qui ont suivi des
cours de programmation.
c Dunod – La photocopie non autorisée est un délit
Le chapitre 11 présentera les tables de hachage, qui reconnaissent les opérations
de dictionnaire I NSERTION, S UPPRESSION et R ECHERCHER. Dans le pire des cas,
le hachage requiert un temps en Q(n) pour effectuer une opération R ECHERCHER,
mais le temps attendu pour les opérations de table de hachage est O(1). L’analyse
du hachage fait appel aux probabilités, mais la majeure partie du chapitre ne requiert
aucune connaissance préalable sur le sujet.
Le chapitre 12 présentera les arbres de recherche binaires qui reconnaissent toutes
les opérations d’ensemble dynamique susmentionnées. Dans le pire des cas, chaque
opération prend un temps Q(n) pour un arbre à n éléments ; mais sur un arbre construit
aléatoirement, le temps attendu pour chaque opération est O(lg n). Les arbres de recherche binaires servent de base à de nombreuses autres structures de données.
Le chapitre 13 introduira les arbres rouge-noir, variante des arbres de recherche
binaires. Contrairement aux arbres de recherche binaires ordinaires, le bon comportement des arbres rouge-noir est garanti : les opérations prennent un temps O(lg n)
dans le pire des cas. Un arbre rouge-noir est un arbre de recherche équilibré ; le chapitre 18 présente un autre type d’arbres de recherche équilibrés, appelés B-arbres.
Bien que les arbres rouge-noir soient quelque peu complexes, on pourra se contenter
de glaner leurs propriétés fondamentales sans être obligé d’étudier le détail de leurs
mécanismes. Par ailleurs, un survol du pseudo code pourra se révéler très instructif.
194
Partie 3
Le chapitre 14 montrera comment étendre les arbres rouge-noir pour leur ajouter
d’autres opérations que les opérations de base précédemment énumérées. Nous verrons d’abord comment gérer dynamiquement les rangs sur un ensemble de clés ; nous
verrons ensuite comment gérer des intervalles de nombres réels.
Chapitre 10
Structures de données élémentaires
Dans ce chapitre, nous étudierons la représentation d’ensembles dynamiques à l’aide
de structures de données simples utilisant des pointeurs. Bien qu’il soit possible de
construire nombre de structures de données complexes grâce aux pointeurs, nous ne
présenterons ici que les plus simples : piles, files, listes chaînées et arbres enracinés.
Nous étudierons également une méthode permettant de construire des objets et des
pointeurs à partir de tableaux.
c Dunod – La photocopie non autorisée est un délit
10.1 PILES ET FILES
Les piles et les files sont des ensembles dynamiques pour lesquels l’élément à supprimer via l’opération S UPPRIMER est défini par la nature intrinsèque de l’ensemble.
Dans une pile, l’élément supprimé est le dernier inséré : la pile met en œuvre le principe dernier entré, premier sorti, ou LIFO (Last-In, First-Out). De même, dans une
file, l’élément supprimé est toujours le plus ancien ; la file met en œuvre le principe
premier entré, premier sorti, ou FIFO (First-In, First-Out). Il existe plusieurs manières efficaces d’implémenter des piles et des files dans un ordinateur. Dans cette
section, nous allons montrer comment les implémenter à l’aide d’un tableau simple.
a) Piles
L’opération I NSÉRER dans une pile est souvent appelée E MPILER et l’opération S UP PRIMER , qui ne prend pas d’élément pour argument, est souvent appelée D ÉPILER .
Ces noms font allusion aux piles rencontrées dans la vie de tous les jours, comme les
piles d’assiettes automatiques en usage dans les cafétérias. L’ordre dans lequel les assiettes sont dépilées est l’inverse de celui dans lequel elles ont été empilées, puisque
seule l’assiette supérieure est accessible.
10 • Structures de données élémentaires
196
Comme on le voit à la figure 10.1, il est possible d’implémenter une pile d’au
plus n éléments avec un tableau P[1 . . n]. Le tableau possède un attribut sommet[P]
qui indexe l’élément le plus récemment inséré. La pile est constituée des éléments
P[1 . . sommet[P]], où P[1] est l’élément situé à la base de la pile et P[sommet[P]] est
l’élément situé au sommet.
Quand sommet[P] = 0, la pile ne contient aucun élément ; elle est vide. On peut
tester si la pile est vide à l’aide de l’opération de requête P ILE -V IDE. Si l’on tente de
dépiler une pile vide, on dit qu’elle déborde négativement, ce qui est en général une
erreur. Si sommet[P] dépasse n, on dit que la pile déborde. (Dans notre pseudo code,
on ne se préoccupera pas d’un débordement éventuel de la pile.)
2
3
4
P 15 6
1
2
9
5
6
7
2
3
4
P 15 6
1
2
9 17 3
sommet [P] = 4
(a)
5
6
7
sommet [P] = 6
(b)
2
3
4
P 15 6
1
2
9 17 3
5
6
7
sommet [P] = 5
(c)
Figure 10.1 Implémentation via un tableau d’une pile P. Les éléments de la pile apparaissent
uniquement aux positions en gris clair. (a) La pile P contient 4 éléments. L’élément sommet est 9.
(b) L’état de la pile P après les appels E MPILER(P, 17) et E MPILER(P, 3). (c) L’état de la pile P
après que l’appel D ÉPILER(P) a retourné 3, qui est l’élément le plus récemment empilé. Bien que
l’élément 3 apparaisse encore dans le tableau, il n’est plus dans la pile ; le sommet est occupé par
l’élément 17.
On peut implémenter chaque opération de pile avec quelques lignes de code.
P ILE -V IDE(P)
1 si sommet[P] = 0
2
alors retourner VRAI
3
sinon retourner FAUX
E MPILER(P, x)
1 sommet[P] ← sommet[P] + 1
2 P[sommet[P]] ← x
D ÉPILER(P)
1 si P ILE -V IDE(P)
2
alors erreur « débordement négatif »
3
sinon sommet[P] ← sommet[P] − 1
4
retourner P[sommet[P] + 1]
10.1
Piles et files
197
La figure 10.1 montre l’effet des opérations de modification E MPILER et D ÉPILER.
Chacune des trois opérations de pile consomme un temps O(1).
b) Files
On appelle E NFILER l’opération I NSÉRER sur une file et on appelle D ÉFILER l’opération S UPPRIMER ; à l’instar de l’opération de pile D ÉPILER, D ÉFILER ne prend
pas d’argument. La propriété FIFO d’une file la fait agir comme une file à un guichet
d’inscription. La file comporte une tête et une queue. Lorsqu’un élément est enfilé,
il prend place à la queue de la file, comme l’étudiant nouvellement arrivé prend sa
place à la fin de la file d’inscription. L’élément défilé est toujours le premier en tête
de la file, de même que l’étudiant qui est servi au guichet est celui qui a attendu le
plus longtemps dans la file. (Heureusement, nous n’avons pas ici à prendre en compte
les éléments qui resquillent).
La figure 10.2 montre une manière d’implémenter une file d’au plus n − 1 éléments à l’aide d’un tableau F[1 . . n]. La file comporte un attribut tête[F] qui indexe, ou pointe vers, sa tête. L’attribut queue[F] indexe le prochain emplacement
où sera inséré un élément nouveau. Les éléments de la file se trouvent aux emplacements tête[F], tête[F] +1, . . . , queue[F] − 1, après quoi l’on « boucle » : l’emplacement 1 suit immédiatement l’emplacement n dans un ordre circulaire. Quand
tête[F] = queue[F], la file est vide. Au départ, on a tête[F] = queue[F] = 1. Quand la
file est vide, tenter de défiler un élément provoque un débordement négatif de la file.
Quand tête[F] = queue[F] + 1, la file est pleine ; tenter d’enfiler un élément provoque
alors un débordement.
c Dunod – La photocopie non autorisée est un délit
Dans nos procédures E NFILER et D ÉFILER, le test d’erreur pour les débordements
a été omis. (L’exercice 10.1.4 vous demande un code capable de tester les deux causes
d’erreurs.)
E NFILER(F, x)
1 F[queue[F]] ← x
2 si queue[F] = longueur[F]
3
alors queue[F] ← 1
4
sinon queue[F] ← queue[F] + 1
D ÉFILER(F)
1 x ← F[tête[F]]
2 si tête[F] = longueur[F]
3
alors tête[F] ← 1
4
sinon tête[F] ← tête[F] + 1
5 retourner x
La figure 10.2 montre l’effet des opérations E NFILER et D ÉFILER. Chaque opération
s’effectue en O(1).
10 • Structures de données élémentaires
198
1
(a)
2
3
4
5
6
F
8
9
10 11 12
15 6
7
9
8
tête [F ] = 7
(b)
F
1
2
3
5
3
4
5
queue [F ] = 3
(c)
F
1
2
3
5
3
4
queue [F ] = 3
6
7
4
queue [F ] = 12
8
9
10 11 12
15 6
9
8
8
9
10 11 12
15 6
9
8
4 17
tête [F ] = 7
5
6
7
4 17
tête [F ] = 8
Figure 10.2 Une file implémentée à l’aide d’un tableau F[1 . . 12]. Les éléments de la file apparaissent uniquement aux positions en gris clair. (a) La file contient 5 éléments, aux emplacements F[7 . . 11]. (b) La configuration de la file après les appels E NFILER(F , 17), E NFILER(F , 3) et
E NFILER(F , 5). (c) La configuration de la file après l’appel D ÉFILER(F) qui retourne la valeur de
clé 15 précédemment en tête de file. La nouvelle tête à la clé 6.
Exercices
10.1.1 En s’inspirant de la figure 10.1, illustrer le résultat de chacune des opérations E MPI LER(P, 4), E MPILER(P, 1), E MPILER(P, 3), D ÉPILER(P), E MPILER(P, 8) et D ÉPILER(P) sur
une pile P, initialement vide, stockée dans le tableau P[1 . . 6].
10.1.2 Expliquer comment implémenter deux piles dans un seul tableau A[1 . . n] de telle
manière qu’aucune pile ne déborde à moins que le nombre total d’éléments dans les deux
piles vaille n. Les opérations E MPILER et D ÉPILER devront s’exécuter dans un temps O(1).
10.1.3 En s’inspirant de la figure 10.2, illustrer le résultat de chacune des opérations E NFI LER(F, 4), E NFILER(F, 1), E NFILER(F, 3), D ÉFILER(F), E NFILER(F, 8) et D ÉFILER(F) sur
une file F, initialement vide, stockée dans le tableau F[1 . . 6].
10.1.4 Réécrire E NFILER et D ÉFILER pour détecter les débordements (normaux et négatifs)
de file.
10.1.5 Alors qu’une pile n’autorise l’insertion et la suppression des éléments qu’à une seule
extrémité et qu’une file autorise l’insertion à une extrémité et la suppression à l’autre extrémité, une file à double entrée autorise l’insertion et la suppression à chaque bout. Écrire
quatre procédures en O(1) pour insérer et supprimer des éléments de chaque côté d’une file à
double entrée construite à partir d’un tableau.
10.1.6 Montrer comment implémenter une file à l’aide de deux piles. Analyser le temps
d’exécution des opérations de file.
10.2
Listes chaînées
199
10.1.7 Montrer comment implémenter une pile à l’aide de deux files. Analyser le temps
d’exécution des opérations de pile.
10.2 LISTES CHAÎNÉES
Une liste chaînée est une structure de données dans laquelle les objets sont arrangés linéairement. Toutefois, contrairement au tableau, pour lequel l’ordre linéaire est
déterminé par les indices, l’ordre d’une liste chaînée est déterminé par un pointeur
dans chaque objet. Les listes chaînées fournissent une représentation simple et souple
pour les ensembles dynamiques, supportant (pas toujours très efficacement) toutes les
opérations énumérées à la page 192.
Comme le montre la figure 10.3, chaque élément d’une liste doublement chaînée L
est un objet comportant un champ clé et deux autres champs pointeurs : succ et préd.
L’objet peut aussi contenir d’autres données satellites. Étant donné un élément x de
la liste, succ[x] pointe sur son successeur dans la liste chaînée et préd[x] pointe sur
son prédécesseur. Si préd[x] = NIL, l’élément x n’a pas de prédécesseur et est donc
le premier élément, aussi appelé tête de liste. Si succ[x] = NIL, l’élément x n’a pas
de successeur et est donc le dernier élément, aussi appelé queue de liste. Un attribut
tête[L] pointe sur le premier élément de la liste. Si tête[L] = NIL, la liste est vide.
c Dunod – La photocopie non autorisée est un délit
préd
clé
succ
(a)
tête [L]
9
16
4
1
(b)
tête [L]
25
9
16
4
(c)
tête [L]
25
9
16
1
1
Figure 10.3 (a) Une liste doublement chaînée L représentant l’ensemble dynamique {1, 4, 9, 16}.
Chaque élément de la liste est un objet avec des champs contenant la clé et des pointeurs (représentés par des flèches) sur les objets suivant et précédent. Le champ succ de la queue et le champ
préd de la tête valent NIL, représenté par un slash. L’attribut tête[L] pointe sur la tête. (b) Après
l’exécution de L ISTE -I NSÉRER(L, x), où clé[x] = 25, la liste chaînée contient à sa tête un nouvel
objet ayant pour clé 25. Ce nouvel objet pointe sur l’ancienne tête de clé 9. (c) Le résultat de
l’appel L ISTE -S UPPRIMER(L, x) ultérieur, où x pointe sur l’objet ayant pour clé 4.
Une liste peut prendre différentes formes. Elle peut être chaînée, ou doublement
chaînée, triée ou non, circulaire ou non. Si une liste chaînée est simple, on omet
le pointeur préd de chaque élément. Si une liste est triée, l’ordre linéaire de la liste
correspond à l’ordre linéaire des clés stockées dans les éléments de la liste ; l’élément
minimum est la tête de la liste et l’élément maximum est la queue. Si la liste est
non-triée, les éléments peuvent apparaître dans n’importe quel ordre. Dans une liste
circulaire, le pointeur préd de la tête de liste pointe sur la queue et le pointeur succ
200
10 • Structures de données élémentaires
de la queue de liste pointe sur la tête. La liste peut donc être vue comme un anneau
d’éléments. Dans le reste de cette section, on suppose que les listes sur lesquelles
nous travaillons sont non triées et doublement chaînées.
a) Recherche dans une liste chaînée
La procédure R ECHERCHE -L ISTE(L, k) trouve le premier élément de clé k dans la
liste L par une simple recherche linéaire et retourne un pointeur sur cet élément. Si
aucun objet de clé k n’apparaît dans la liste, la procédure retourne NIL. Si l’on prend
la liste chaînée de la figure 10.3(a), l’appel R ECHERCHE -L ISTE(L, 4) retourne un
pointeur sur le troisième élément et l’appel R ECHERCHE -L ISTE(L, 7) retourne NIL.
R ECHERCHE -L ISTE(L, k)
1 x ← tête[L]
2 tant que x fi NIL et clé[x] fi k
3
faire x ← succ[x]
4 retourner x
Pour parcourir une liste de n objets, la procédure R ECHERCHE -L ISTE s’exécute en
Q(n) dans le cas le plus défavorable, puisqu’on peut être obligé de parcourir la liste
entière.
b) Insertion dans une liste chaînée
Étant donné un élément x dont le champ clé a déjà été initialisé, la procédure
I NSÉRER -L ISTE « greffe » x à l’avant de la liste chaînée, comme on le voit sur la
figure 10.3(b).
I NSÉRER -L ISTE(L, x)
1 succ[x] ← tête[L]
2 si tête[L] fi NIL
3
alors préd[tête[L]] ← x
4 tête[L] ← x
5 préd[x] ← NIL
Le temps d’exécution de I NSÉRER -L ISTE sur une liste de n éléments est O(1).
c) Suppression dans une liste chaînée
La procédure S UPPRIMER -L ISTE élimine un élément x d’une liste chaînée L. Il faut
lui fournir un pointeur sur x et elle se charge alors de « détacher » x de la liste en mettant les pointeurs à jour. Si l’on souhaite supprimer un élément ayant une clé donnée,
on doit commencer par appeler R ECHERCHE -L ISTE pour récupérer un pointeur sur
l’élément.
10.2
Listes chaînées
201
S UPPRIMER -L ISTE(L, x)
1 si préd[x] fi NIL
2
alors succ[préd[x]] ← succ[x]
3
sinon tête[L] ← succ[x]
4 si succ[x] fi NIL
5
alors préd[succ[x]] ← préd[x]
La figure 10.3(c) montre comment un élément est supprimé dans une liste chaînée.
S UPPRIMER -L ISTE s’exécute dans un temps en O(1) ; mais si l’on souhaite supprimer un élément à partir de sa clé, il faut un temps Q(n) dans le cas le plus défavorable,
car on doit commencer par appeler R ECHERCHE -L ISTE.
d) Sentinelles
Le code de S UPPRIMER -L ISTE serait plus simple si l’on pouvait ignorer les conditions aux limites en tête et en queue de liste.
(a)
nil[L]
(b)
nil[L]
9
16
4
1
(c)
nil[L]
25
9
16
4
(d)
nil[L]
25
9
16
4
1
c Dunod – La photocopie non autorisée est un délit
Figure 10.4 Liste circulaire doublement chaînée, avec sentinelle. La sentinelle nil[L] apparaît
entre la tête et la queue. L’ attribut tête[L] n’est plus nécessaire, car on peut accéder à la tête de
la liste via succ[nil[L]]. (a) Une liste vide. (b) La liste chaînée de la figure 10.3(a), avec la clé 9 en
tête et la clé 1 en queue. (c) La liste après exécution de I NSÉRER -L ISTE (L, x), où clé[x] = 25. Le
nouvel objet devient la tête de la liste. (d) La liste après suppression de l’objet ayant la clé 1. La
nouvelle queue est l’objet ayant la clé 4.
S UPPRIMER -L ISTE (L, x)
1 succ[préd[x]] ← succ[x]
2 préd[succ[x]] ← préd[x]
Une sentinelle est un objet fictif permettant de simplifier les conditions aux limites.
Par exemple, supposons qu’on fournisse avec une liste L un objet nil[L] qui représente NIL mais contienne tous les champs des autres éléments de la liste. Partout où
nous avons une référence à NIL dans le code d’implémentation de la liste, on le remplace par une référence à la sentinelle nil[L]. Comme le montre la figure 10.4, cela
transforme une liste doublement chaînée ordinaire en une liste circulaire doublement
202
10 • Structures de données élémentaires
chaînée, avec la sentinelle placée entre la tête et la queue ; le champ succ[nil[L]]
pointe sur la tête de la liste, et préd[nil[L]] pointe sur la queue. De même, le champ
succ de la queue et le champ préd de la tête pointent tous les deux vers nil[L]. Comme
succ[nil[L]] pointe sur la tête, on peut complètement éliminer l’attribut tête[L], en
remplaçant ses références par des références à succ[nil[L]]. Une liste vide ne contient
que la sentinelle, puisque succ[nil[L]] et préd[nil[L]] peuvent tous deux être initialisés à nil[L].
Le code de R ECHERCHE -L ISTE reste identique au précédent, mais inclut les modifications des références à NIL et à tête[L] proposées ci-dessus.
R ECHERCHE -L ISTE (L, k)
1 x ← succ[nil[L]]
2 tant que x fi nil[L] et clé[x] fi k
3
faire x ← succ[x]
4 retourner x
On utilise la procédure de deux lignes S UPPRIMER -L ISTE pour supprimer un élément de la liste. On utilise la procédure suivante pour insérer un élément dans la
liste.
I NSÉRER -L ISTE (L, x)
1 succ[x] ← succ[nil[L]]
2 préd[succ[nil[L]]] ← x
3 succ[nil[L]] ← x
4 préd[x] ← nil[L]
La figure 10.4 montre l’effet de I NSÉRER -L ISTE et S UPPRIMER -L ISTE sur une liste
témoin.
Les sentinelles réduisent rarement les bornes asymptotiques des opérations sur
les structures de données, mais elles réduisent parfois les facteurs constants. L’avantage des sentinelles à l’intérieur des boucles est en général un problème de clarté du
code plutôt que de vitesse ; le code de la liste chaînée, par exemple, est simplifié par
l’utilisation de sentinelles, mais n’économise qu’un temps O(1) dans les procédures
I NSÉRER -L ISTE et S UPPRIMER -L ISTE . Cela dit, dans d’autres situations, l’utilisation des sentinelles aident à compacter le code à l’intérieur d’une boucle, réduisant
ainsi le coefficient de, disons n ou n2 , dans le temps d’exécution.
On ne devra pas faire un usage inconsidéré des sentinelles. Si l’on travaille sur
beaucoup de petites listes, l’espace supplémentaire nécessaire pour stocker leur sentinelles peut représenter une dépense de mémoire significative. Dans ce livre, on n’utilisera les sentinelles que lorsqu’elles simplifient réellement le code.
10.3
Implémentation des pointeurs et des objets
203
Exercices
10.2.1 Peut-on implémenter l’opération d’ensemble dynamique I NSÉRER sur une liste simplement chaînée pour qu’elle s’exécute dans un temps en O(1) ? Et pour S UPPRIMER ?
10.2.2 Implémenter une pile à l’aide d’une liste simplement chaînée L. Les opérations E M PILER et D ÉPILER devront encore s’exécuter en O(1).
10.2.3 Implémenter une file à l’aide d’une liste simplement chaînée L. Les opérations E N FILER et D ÉFILER devront encore s’exécuter en O(1).
10.2.4 Au niveau de l’écriture, chaque itération de boucle dans la procédure R ECHERCHE L ISTE exige deux tests : un pour x fi nil[L] et un pour clé[x] fi k. Montrer comment éliminer
le test x fi nil[L] dans chaque itération.
10.2.5 Implémenter les opérations de dictionnaire I NSÉRER, S UPPRIMER et R ECHERCHER
à l’aide de listes circulaires simplement chaînées. Quels sont les temps d’exécution de vos
procédures ?
10.2.6 L’opération U NION sur les ensembles dynamiques prend deux ensembles disjoints
S1 et S2 en entrée et retourne un ensemble S = S1 ∪ S2 constitué de tous les éléments de
S1 et S2 . Les ensembles S1 et S2 sont en général détruits par l’opération. Montrer comment
implémenter U NION pour qu’elle s’exécute en O(1), en utilisant une structure de données
liste adaptée.
c Dunod – La photocopie non autorisée est un délit
10.2.7 Donner une procédure non récursive en Q(n) qui inverse l’ordre d’une liste simplement chaînée à n éléments. En dehors de l’espace nécessaire pour contenir la liste elle-même,
la procédure ne devra pas utiliser d’espace de stockage non constant.
10.2.8 Expliquer comment implémenter des listes doublement chaînées à l’aide d’une
seule valeur de pointeur sp[x] par élément au lieu des deux habituelles (succ et préd). On
suppose que toutes les valeurs de pointeurs peuvent être interprétées comme des entiers sur
k bits et on définit sp[x] par sp[x] = succ[x] XOR préd[x], le « ou exclusif » sur k bits de
succ[x] et préd[x]. (La valeur NIL est représentée par 0.) Ne pas oublier de décrire toutes
les informations utiles pour accéder à la tête de liste. Montrer comment implémenter les
opérations R ECHERCHER, I NSÉRER et S UPPRIMER sur une telle liste. Montrer également
comment on peut inverser une telle liste dans un temps O(1).
10.3 IMPLÉMENTATION DES POINTEURS ET DES OBJETS
Comment des pointeurs et des objets peuvent-ils être implémentés dans des langages
comme Fortran, qui ne les proposent pas en standard ? Dans cette section, nous verrons deux manières d’implémenter des structures de données chaînées sans faire appel à un type de données pointeur explicite. Nous créerons les objets et les pointeurs
à partir de tableaux et d’indices de tableau.
10 • Structures de données élémentaires
204
a) Une représentation des objets par tableaux multiples
On peut représenter une collection d’objets ayant les mêmes champs en utilisant un
tableau pour chaque champ. Par exemple, la figure 10.5 montre comment implémenter la liste chaînée de la figure 10.3(a) avec trois tableaux. Le tableau clé contient
les valeurs des clés qui se trouvent dans l’ensemble dynamique et les pointeurs sont
stockés dans les tableaux succ et préd. Pour un indice de tableau x donné, clé[x],
succ[x] et préd[x] représentent un objet de la liste chaînée. Selon cette interprétation,
un pointeur x est tout simplement un indice commun aux tableaux clé, succ et préd.
Dans la figure 10.3(a), l’objet de clé 4 suit l’objet de clé 16 dans la liste chaînée.
Dans la figure 10.5, la clé 4 apparaît dans clé[2] et la clé 16 apparaît dans clé[5].
On a donc succ[5] = 2 et préd[2] = 5. Bien que la constante NIL apparaisse dans
le champ succ de la queue et le champ préd de la tête, on utilise habituellement un
entier (comme 0 ou −1) qui ne peut pas représenter d’indice réel dans les tableaux.
Une variable L contient l’indice de la tête de liste.
Dans notre pseudo code, nous avons utilisé des crochets pour représenter à la fois
l’indexation d’un tableau et la sélection d’un champ (attribut) d’un objet. Dans l’un
ou l’autre cas, la signification de clé[x], succ[x] et préd[x] est cohérente avec les
pratiques d’implémentation.
b) Représentation des objets par un tableau unique
Les mots d’une mémoire d’ordinateur sont généralement adressés par des entiers
compris entre 0 et M − 1, où M est un entier suffisamment grand. Dans nombre de
langages de programmation, un objet occupe un ensemble d’emplacements contigus dans la mémoire de l’ordinateur. Un pointeur sur cet objet est tout simplement
l’adresse du premier emplacement que l’objet occupe en mémoire ; pour accéder aux
autres emplacements mémoire occupés par l’objet, il suffit d’incrémenter le pointeur.
L
7
succ
clé
préd
1
2
3
4
5
3
1
2
4
5
2
16
7
6
7
8
5
9
Figure 10.5 La liste chaînée de la figure 10.3(a) représentée par les tableaux clé, succ et préd.
Chaque tranche verticale des tableaux représente un même objet. Les pointeurs stockés correspondent aux indices de tableau montrés au sommet ; les flèches montrent la façon dont on doit
les interpréter. Les positions en gris clair contiennent les éléments de la liste. La variable L contient
l’indice de la tête.
10.3
Implémentation des pointeurs et des objets
205
On peut employer la même stratégie pour l’implémentation des objets dans des
environnements de programmation qui ne fournissent pas de types de données pointeur explicites. Par exemple, la figure 10.6 montre comment un tableau unique A peut
servir à stocker la liste chaînée des figures 10.3(a) et 10.5. Un objet occupe un soustableau contigu A[j . . k]. Chaque champ de l’objet correspond à un décalage dans
l’intervalle 0 à k − j, et l’indice j est un pointeur sur l’objet. Dans la figure 10.6, les
déplacements correspondant à clé, succ et préd valent respectivement 0, 1 et 2. Pour
lire la valeur de préd[i], étant donné un pointeur i, on ajoute à la valeur i du pointeur
le déplacement 2, ce qui nous permet de lire A[i + 2].
L
1
19
A
2
3
4
5
6
7
4
7 13 1
8
9
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
4
16 4 19
9 13
clé préd
succ
Figure 10.6 La liste chaînée des figures 10.3(a) et 10.5 représentée par un même tableau A.
Chaque élément de la liste est un objet qui occupe un sous-tableau contigu de longueur 3 à l’intérieur du tableau. Les trois champs clé, succ et préd correspondent respectivement aux décalages
0, 1 et 2. L’indice du premier élément de l’objet est un pointeur sur cet objet. Les objets contenant
les éléments de la liste sont en gris clair et les flèches montrent l’ordre de la liste.
La représentation par tableau unique est souple, au sens où elle permet à des objets
de longueurs différentes d’être conservés dans le même tableau. La gestion d’une
telle collection hétérogène d’objets est plus compliquée que celle d’une collection
homogène, où tous les objets ont les mêmes champs. Comme la plupart des structures de données que nous considérerons sont composées d’éléments homogènes, la
représentation par tableaux multiples sera bien suffisante pour nos besoins.
c Dunod – La photocopie non autorisée est un délit
c) Allocation et libération des objets
Pour insérer une clé dans un ensemble dynamique représenté par une liste doublement chaînée, il faut allouer un pointeur sur un objet inutilisé dans la représentation
de la liste chaînée. Il est donc utile de gérer, dans la représentation de la liste chaînée,
le stockage des objets provisoirement inutilisés, de manière à pouvoir en allouer un
en cas de besoin. Sur certains systèmes, un récupérateur de place mémoire (garbage
collector) a pour rôle de déterminer les objets qui ne sont pas utilisés. Cependant, de
nombreuses applications sont assez simples pour pouvoir endosser la responsabilité
de retourner un objet non utilisé à un gestionnaire de stockage. Nous allons maintenant étudier le problème de l’allocation et de la libération d’objets homogènes en
nous aidant de l’exemple d’une liste doublement chaînée représentée par des tableaux
multiples.
10 • Structures de données élémentaires
206
Supposons que les tableaux servant à cette représentation aient pour longueur m
et qu’à un certain moment l’ensemble dynamique contienne n m éléments. Il y
a donc n objets qui représentent les éléments présents dans l’ensemble dynamique,
les m − n autres objets étant libres ; les objets libres peuvent servir à représenter des
éléments qui seront insérés plus tard dans l’ensemble dynamique.
On conserve les objets libres dans une liste simplement chaînée, qu’on appelle
liste libre. La liste libre n’utilise que le tableau succ, qui stocke les pointeurs succ de
la liste. La tête de la liste libre est contenue dans la variable globale libre. Lorsque
l’ensemble dynamique représenté par la liste chaînée L n’est pas vide, la liste libre
peut être entrelacée avec la liste L, comme le montre la figure 10.7. On notera que
chaque objet de la représentation est soit dans la liste L soit dans la liste libre, mais
pas dans les deux à la fois.
libre
4
L
7
1
succ
clé
préd
2
3
3
4
5
4
5
6
7
8
2
1
5 6
9
1
2
16
7
8
(a)
libre
5
L
4
1
succ
clé
préd
8
L
4
1
succ
clé
préd
2
3
3
7 2 1 5 6
1 25 16
9
2
7
4
4
5
4
5
6
7
8
(b)
2
3
5
6
7
3
7 8
1 25
2
1
2 6
4
7
4
libre
8
9
4
(c)
Figure 10.7 L’effet des procédures A LLOUER -O BJET et L IBÉRER -O BJET. (a) La liste de la figure 10.5 (en gris clair) et une liste libre (en gris foncé). Les flèches montrent la structure de
la liste libre. (b) Le résultat de l’appel à A LLOUER -O BJET() (qui retourne l’indice 4), de l’initialisation de clé[4] à 25 et de l’appel à I NSÉRER -L ISTE(L, 4). La nouvelle tête de la liste libre est l’objet 8,
qui était auparavant succ[4] dans la liste libre. (c) Après exécution de S UPPRIMER -L ISTE(L, 5), on
appelle L IBÉRER -O BJET(5). L’objet 5 devient la nouvelle tête de la liste libre, avec l’objet 8 pour
successeur dans la liste libre.
La liste libre est une pile : le prochain objet qui sera alloué sera celui qui sera libéré en dernier. On peut utiliser une implémentation pour liste des opérations de pile
E MPILER et D ÉPILER pour mettre en œuvre respectivement les procédures d’allocation et de libération des objets. On suppose que la variable globale libre utilisée dans
les procédures suivantes pointe sur le premier élément de la liste libre.
L IBÉRER -O BJET(x)
1 succ[x] ← libre
2 libre ← x
10.3
Implémentation des pointeurs et des objets
207
A LLOUER -O BJET()
1 si libre = NIL
2
alors erreur « plus assez d’espace disponible »
3
sinon x ← libre
4
libre ← succ[x]
5
retourner x
La liste libre contient initialement les n objets non alloués. Lorsque la liste libre
est épuisée, la procédure A LLOUER -O BJET signale une erreur. Il est fréquent de
mettre une même liste libre à la disposition de plusieurs listes chaînées. La figure 10.8
montre deux listes chaînées et une liste libre entrelacées, représentées par les tableaux
clé, succ et préd.
libre 10
L2 9
L1 3
1
succ 5
2
3
4
6
8
clé k1 k2 k3
préd 7 6
5
6
7
2
1
k5 k6 k7
1 3 9
8
9
10
7 4
k9
Figure 10.8 Deux listes chaînées, L1 (en gris clair) et L2 (en gris foncé) et une liste libre (en noir)
entrelacées.
Les deux procédures s’exécutent en O(1), ce qui les rend très pratiques. On peut
les modifier pour qu’elles fonctionnent sur n’importe quelle collection d’objets homogènes, en faisant en sorte que l’un quelconque des champs de l’objet fasse office
de champ succ de la liste libre.
Exercices
c Dunod – La photocopie non autorisée est un délit
10.3.1 Dessiner une représentation de la séquence 13, 4, 8, 19, 5, 11 stockée sous la forme
d’une liste doublement chaînée utilisant la représentation par tableaux multiples. Faire de
même pour la représentation par tableau unique.
10.3.2 Écrire les procédures A LLOUER -O BJET et L IBÉRER -O BJET pour une collection homogène d’objets implémentés à l’aide de la représentation par tableau unique.
10.3.3 Pourquoi est-il inutile de (ré)initialiser le champ préd des objets dans l’implémentation des procédures A LLOUER -O BJET et L IBÉRER -O BJET ?
10.3.4 On souhaite souvent stocker tous les éléments d’une liste doublement chaînée de la
manière la plus compacte possible ; on utilise, par exemple, les m premiers emplacements
d’indice pour la représentation par tableaux multiples. (C’est le cas dans un environnement
informatique à mémoire virtuelle paginée.) Expliquer comment implémenter les procédures
208
10 • Structures de données élémentaires
A LLOUER -O BJET et L IBÉRER -O BJET de manière à compacter la représentation. On supposera que, à l’extérieur de la liste elle-même, il n’existe aucun pointeur vers les éléments de la
liste chaînée. (Conseil : utiliser l’implémentation par tableau d’une pile.)
10.3.5 Soit L une liste doublement chaînée de longueur m stockée dans des tableaux clé,
préd et succ de longueur n. On suppose que ces tableaux sont gérés par les procédures
A LLOUER -O BJET et L IBÉRER -O BJET qui gèrent une liste libre doublement chaînée F. On
suppose aussi que, parmi les n éléments, m exactement sont dans la liste L et n − m sont
dans la liste libre. Écrire une procédure D ENSIFIER -L ISTE(L, F) qui, étant données la liste L
et la liste libre F, déplace les éléments à l’intérieur de L pour qu’ils occupent les positions
1, 2, . . . , m du tableau et ajuste la liste libre F pour qu’elle occupe désormais les positions
m + 1, m + 2, . . . , n. Le temps d’exécution de la procédure devra être en Q(m), et la procédure
ne devra utiliser qu’une quantité constante d’espace supplémentaire. Justifier soigneusement
la validité de la procédure.
10.4 REPRÉSENTATION DES ARBORESCENCES
Les méthodes de représentation des listes données dans la section précédente
s’étendent à n’importe quelle structure de données homogène. Dans cette section,
nous nous intéressons particulièrement au problème de la représentation des arborescences à l’aide de structures de données chaînées. Nous commencerons par les
arborescences binaires, puis nous présenterons une méthode pour les arborescences
dont les nœuds peuvent avoir un nombre arbitraire d’enfants.
Chaque nœud est représenté par un objet. Comme pour les listes chaînées, on suppose que chaque nœud contient un champ clé. Les autres champs vitaux sont des
pointeurs sur les autres nœuds et ils varient selon le type d’arborescence.
a) Arborescences binaires
Comme le montre la figure 10.9, on utilise les champs p, gauche et droit pour stocker
les pointeurs vers le père, le fils gauche et le fils droit de chaque nœud d’un arbre
binaire T. Si p[x] = NIL, alors x est la racine. Si le nœud x n’a pas de fils gauche,
alors gauche[x] = NIL et il en va de même pour le fils droit. La racine de l’arbre T est
pointée par l’attribut racine[T]. Si racine[T] = NIL, alors l’arbre est vide.
b) Arborescences avec ramifications non bornées
Le schéma de représentation d’une arborescence binaire peut être étendu à n’importe quelle classe d’arbres pour laquelle le nombre de fils de chaque nœud vaut
au plus une certaine constante k : on remplace les champs gauche et droite par
fils1 , fils2 , . . . , filsk . Ce schéma n’est plus valable quand le nombre de fils n’est pas
borné, puisqu’on ne sait pas combien de champs (tableaux dans la représentation par
tableaux multiples) allouer à l’avance. De plus, même si le nombre k de fils est borné
10.4
Représentation des arborescences
209
racine [T]
Figure 10.9 La représentation d’une arborescence binaire T. Chaque nœud x possède les champs
p[x] (parent), gauche[x] (enfant de gauche), et droite[x] (enfant de droite). Les champs clé n’apparaissent pas sur la figure.
par une grande constante, mais que la plupart des nœuds n’ont qu’un petit nombre de
fils, on risque de gaspiller beaucoup de mémoire.
Heureusement, il existe un schéma astucieux permettant d’utiliser des arborescences binaires pour représenter des arborescences ayant un nombre arbitraire de fils.
Il a l’avantage de n’utiliser qu’un espace en O(n) pour une arborescence quelconque
à n nœuds. La représentation fils-gauche, frère-droit est montrée à la figure 10.10.
Comme précédemment, chaque nœud contient un pointeur p sur son père et racine[T]
pointe sur la racine de l’arborescence T. Toutefois, au lieu d’avoir un pointeur sur
chacun de ses fils, chaque nœud x ne possède que deux pointeurs :
1) fils-gauche[x] pointe sur le fils le plus à gauche du nœud x et
c Dunod – La photocopie non autorisée est un délit
2) frère-droite[x] pointe sur le frère de x situé immédiatement à sa droite.
Si le nœud x n’a aucun fils, alors fils-gauche[x] = NIL, et si x est le fils le plus à droite
de son père, alors frère-droit[x] = NIL.
c) Autres représentations d’arborescences
Il arrive qu’on représente autrement les arborescences. Au chapitre 6, par exemple,
nous avons représenté un tas, basé sur une arborescence binaire complète, par un
tableau unique et un indice. Les arborescences que nous étudierons au chapitre 21
sont parcourues uniquement du bas vers le haut, ce qui permet de ne garder que les
pointeurs parent ; il n’existe aucun pointeur vers un fils. De nombreux autres schémas
sont possibles. Le meilleur choix dépend de l’application.
10 • Structures de données élémentaires
210
racine [T]
Figure 10.10 La représentation fils-gauche, frère-droite d’une arborescence T. Chaque nœud x
possède les champs p[x] (parent), fils-gauche[x] (en bas à gauche) et frère-droit[x] (en bas à
droite). Les clés n’apparaissent pas sur la figure.
Exercices
10.4.1 Dessiner l’arborescence binaire dont l’indice de la racine est 6 et qui est représentée
indice clé gauche droite
1
12
7
3
NIL
2
15
8
3
4
10
NIL
4
10
5
9
par les champs suivants.
NIL
NIL
5
2
6
18
1
4
NIL
NIL
7
7
8
14
6
2
NIL
NIL
9
21
10
5
NIL
NIL
10.4.2 Écrire une procédure récursive en O(n) qui, étant donné une arborescence binaire à n
nœuds, affiche la clé de chaque nœud de l’arborescence.
10.4.3 Écrire une procédure non-récursive en O(n) qui, étant donné une arborescence binaire
à n nœuds, affiche la clé de chaque nœud de l’arborescence. Utiliser une pile comme structure
de données auxiliaire.
10.4.4 Écrire une procédure en O(n) qui affiche toutes les clés d’une arborescence arbitraire
à n nœuds, l’arborescence étant modélisée à l’aide de la représentation fils-gauche, frère-droit.
Problèmes
211
10.4.5 Écrire une procédure non récursive en O(n) qui, étant donné une arborescence binaire à n nœuds, affiche la clé de chaque nœud. On n’utilisera qu’un espace constant en plus
de l’arborescence elle-même et l’on ne modifiera pas l’arborescence, même temporairement,
durant la procédure.
10.4.6 La représentation fils-gauche, frère-droit d’une arborescence arbitraire utilise trois
pointeurs à chaque nœud : fils-gauche, frère-droite et père. Quel que soit le nœud, son parent
est accessible et identifiable en temps constant ; tous ses enfants sont accessibles et identifiables en temps linéaire par rapport au nombre d’enfants. Montrer comment, en utilisant
uniquement deux pointeurs et une valeur booléenne en chaque nœud, on peut atteindre et
identifier le parent ou tous les enfants d’un nœud en un temps qui est linéaire par rapport au
nombre d’enfants.
PROBLÈMES
10.1. Comparaisons entre listes
Pour chacun des quatre types de liste du tableau suivant, quel est le temps d’exécution asymptotique dans le cas le plus défavorable pour chacune des opérations
d’ensembles dynamiques énumérées ?
chaînée
simple,
non-triée
chaînée
simple,
triée
chaînée
double,
non-triée
chaînée
double,
triée
R ECHERCHER(L, k)
I NSÉRER(L, x)
S UPPRIMER(L, x)
S UCCESSEUR(L, x)
P RÉDÉCESSEUR(L, x)
c Dunod – La photocopie non autorisée est un délit
M INIMUM(L)
M AXIMUM(L)
10.2. Tas fusionnables avec des listes chaînées
Un tas fusionnable supporte les opérations suivantes : C ONSTRUIRE -TAS (qui crée
un tas vide), I NSÉRER, M INIMUM, E XTRAIRE -M IN et U NION. (1) Montrer comment
implémenter les tas fusionnables à l’aide de listes chaînées dans chacun des cas suivants. Essayer de rendre chaque opération aussi efficace que possible. Analyser le
(1) Comme on a défini un tas fusionnable pour qu’il reconnaisse M INIMUM et E XTRAIRE -M IN, on peut
parler de tas min fusionnable. Inversement, si le tas reconnaît M AXIMUM et E XTRAIRE -M AX, on parlera de
tas max fusionnable.
212
10 • Structures de données élémentaires
temps d’exécution de chaque opération en fonction de la taille du ou des ensembles
dynamiques sur lesquels elles agissent.
a. Les listes sont triées.
b. Les listes ne sont pas triées.
c. Les listes ne sont pas triées et les ensembles dynamiques à fusionner sont disjoints.
10.3. Parcours d’une liste triée compacte
L’exercice 10.3.4 demandait de maintenir à jour une liste à n éléments confinés dans
les n premières positions d’un tableau. On supposera que toutes les clés sont distinctes
et que la liste compacte est également triée, autrement dit que clé[i] < clé[succ[i]]
pour tout i = 1, 2, . . . , n tel que succ[i] fi NIL. Partant de ces hypothèses, vous allez
montrer que l’algorithme randomisé
suivant peut servir à faire des recherches dans la
√
liste avec un temps attendu O( n).
R ECHERCHE -L ISTE -C OMPACT(L, n, k)
1 i ← tête[L]
2 tant que i fi NIL and clé[i] < k
3
faire j ← R ANDOM(1, n)
4
si clé[i] < clé[j] and clé[j] k
5
alors i ← j
6
si clé[i] = k
7
alors retourner i
8
i ← succ[i]
9 si i = NIL ou clé[i] > k
10
alors retourner NIL
11
sinon retourner i
Si l’on ignore les lignes 3–7 de la procédure, on a un algorithme classique de
recherche dans une liste chaînée triée, l’indice i pointant vers chaque position de la
liste à tour de rôle. La recherche se termine quand l’indice i « dépasse » la fin de la
liste ou quand clé[i] k. Dans ce dernier cas, si clé[i] = k, alors on a visiblement
trouvé une clé ayant la valeur k. Si, en revanche, clé[i] > k, alors il n’existe aucune
clé ayant la valeur k, auquel cas la seule chose à faire est de mettre immédiatement
fin à la recherche.
Les lignes 3–7 essaient de sauter à une position j choisie aléatoirement. Un tel saut
est intéressant si clé[j] est supérieur à clé[i] mais pas à k ; en pareil cas, j représente un
emplacement de la liste que i aurait dû atteindre de la manière séquentielle classique.
Comme la liste est compacte, on sait qu’un choix quelconque de j entre 1 et n indexe
un objet de la liste, et non un emplacement de la liste libre.
Au lieu d’analyser les performances de R ECHERCHE -L ISTE -C OMPACTE directement, on va analyser un algorithme voisin, R ECHERCHE -L ISTE -C OMPACTE , qui
Problèmes
213
exécute deux boucles séparées. Cet algorithme prend un paramètre supplémentaire t
qui définit un majorant pour le nombre d’itérations de la première boucle.
R ECHERCHE -L ISTE -C OMPACTE (L, n, k, t)
1 i ← tête[L]
2 pour q ← 1 à t
3
faire j ← R ANDOM(1, n)
4
si clé[i] < clé[j] and clé[j] k
5
alors i ← j
6
si clé[i] = k
7
alors retourner i
8 tant que i fi NIL et clé[i] < k
9
faire i ← succ[i]
10 si i = NIL ou clé[i] > k
11
alors retourner NIL
12
sinon retourner i
Pour comparer l’exécution des algorithmes R ECHERCHE -L ISTE -C OMPACTE(L, k) et
R ECHERCHE -L ISTE -C OMPACTE (L, k, t), on supposera que la suite d’entiers retournée par les appels R ANDOM(1, n) est la même pour les deux algorithmes.
a. Supposez que R ECHERCHE -L ISTE -C OMPACTE(L, k) prenne t itérations de la
boucle tant que des lignes 2–8. Prouver que R ECHERCHE -L ISTE -C OMPACTE (L, k, t)
retourne la même réponse et que le nombre total d’itérations des deux boucles
pour et tant que de R ECHERCHE -L ISTE -C OMPACTE est au moins t.
c Dunod – La photocopie non autorisée est un délit
Dans l’appel R ECHERCHE -L ISTE -C OMPACTE (L, k, t), soit Xt la variable aléatoire
qui décrit la distance, dans la liste chaînée (c’est-à-dire, à travers la chaîne des pointeurs succ), entre la position i et la clé désirée k, après t itérations de la boucle pour
des lignes 2–7.
b. Prouver que le temps d’exécution attendu de R ECHERCHE -L ISTE -C OMPACTE (L, k, t)
est O(t + E [Xt ]).
c. Montrer que E [Xt ] nr=1 (1 − r/n)t . (Conseil : Utiliser l’équation C.24).
t
t+1
d. Montrer que n−1
r=0 r n /(t + 1).
e. Prouver que E [Xt ] n/(t + 1).
f. Montrer que R ECHERCHE -L ISTE -C OMPACTE (L, k, t) s’exécute en un temps attendu de O(t + n/t).
g. En conclure
que R ECHERCHE -L ISTE -C OMPACTE s’exécute en un temps attendu
√
de O( n).
h. Pourquoi suppose-t-on que toutes les clés sont distinctes dans R ECHERCHE L ISTE -C OMPACTE ? Prouver que les sauts aléatoires n’améliorent pas forcément
le temps d’exécution asymptotique si la liste contient des doublons.
214
10 • Structures de données élémentaires
NOTES
Aho, Hopcroft et Ullman [6] et Knuth [182] sont d’excellentes références pour les structures
de données élémentaires. Il existe beaucoup d’autres ouvrages qui traitent des structures de
données fondamentales et de leurs implémentations dans tel ou tel langage de programmation. Citons, entre autres, Goodrich et Tamassia [128], Main [209], Shaffer [273] et Weiss
[310, 312, 313]. Gonnet [126] fournit des données expérimentales sur les performances de
nombreuses opérations de structure de données.
L’origine des piles et des files comme structures de données informatiques n’est pas certaine, puisque ces notions existaient déjà en mathématiques et dans les bureaux avant l’introduction des ordinateurs. Knuth [182] attribue à A. M. Turing le développement de piles pour
gérer les liens entre sous-programmes en 1947.
Les structures de données basées sur les pointeurs semblent également provenir de la vie
quotidienne. Selon Knuth, les pointeurs étaient apparemment utilisés dans les tout premiers
ordinateurs avec mémoire à tambours. Le langage A-1 développé par G. M. Hopper en 1951
représentait les formules algébriques sous la forme d’arborescences binaires. Knuth attribue
au langage IPL-II, développé en 1956 par A. Newell, J. C. Shaw et H. A. Simon, la le fait
d’avoir reconnu l’importance des pointeurs et d’avoir popularisé leur emploi. Leur langage
IPL-III, développé en 1957, incluait des opérations de pile explicites.
Chapitre 11
Tables de hachage
c Dunod – La photocopie non autorisée est un délit
De nombreuses applications font appel à des ensembles dynamiques qui ne supportent que les opérations de dictionnaire I NSÉRER, R ECHERCHER et S UPPRIMER.
Par exemple, un compilateur doit gérer une table de symboles, dans laquelle les clés
des éléments sont des chaînes de caractères arbitraires qui correspondent aux identificateurs du langage. Une table de hachage est une structure de données permettant
d’implémenter efficacement des dictionnaires. Bien que la recherche d’un élément
dans une table de hachage puisse être aussi longue que la recherche d’un élément
dans une liste chaînée (Q(n) dans le cas le plus défavorable), en pratique le hachage
est très efficace. Avec des hypothèses raisonnables, le temps moyen de recherche
d’un élément dans une table de hachage est O(1).
Une table de hachage est une généralisation de la notion simple de tableau ordinaire. L’adressage direct dans un tableau ordinaire utilise efficacement la possibilité
d’examiner une position arbitraire dans un tableau en temps O(1). La section 11.1
étudie plus en détail l’adressage direct. L’adressage direct est applicable lorsqu’on
est en mesure d’allouer un tableau qui possède une position pour chaque clé possible.
Quand le nombre des clés effectivement stockées est petit comparé au nombre
total des clés possibles, les tables de hachage remplacent efficacement l’adressage
direct d’un tableau, puisqu’une table de hachage utilise généralement un tableau de
taille proportionnelle au nombre des clés effectivement stockées. Au lieu d’utiliser
la clé directement comme indice du tableau, l’indice est calculé à partir de la clé.
La section 11.2 présentera les concepts fondamentaux, et notamment le « chaînage »
11 • Tables de hachage
216
comme méthode de gestion des « collisions » (il y a collision quand plusieurs clés
donnent le même indice de tableau). La section 11.3 expliquera comment des indices
de tableau sont calculés à partir de clé via des fonctions de hachage. Nous présenterons et analyserons plusieurs variantes du mécanisme de base. La section 11.4 étudiera l’« adressage ouvert », qui est une autre technique de gestion des collisions. La
conclusion de tout cela est que le hachage est une technique extrêmement efficace
et commode : les opérations fondamentales de dictionnaire ne prennent qu’un temps
O(1) en moyenne. La section 11.5 expliquera comment le « hachage parfait » permet
de faire de recherches avec un temps O(1) dans le cas le plus défavorable, quand
l’ensemble des clés stockées est statique (c’est-à-dire, quand il ne change plus une
fois qu’il a été stocké).
11.1 TABLES À ADRESSAGE DIRECT
L’adressage direct est une technique simple qui fonctionne bien lorsque l’univers
U des clés est raisonnablement petit. Supposons qu’une application ait besoin d’un
ensemble dynamique dans lequel chaque élément possède une clé prise dans l’univers
U = {0, 1, . . . , m − 1}, où m n’est pas trop grand. On supposera que deux éléments
ne peuvent pas avoir la même clé.
Pour représenter l’ensemble dynamique, on utilise un tableau, aussi appelé table à
adressage direct, T[0 . . m − 1], dans lequel chaque position, ou alvéole, correspond à
une clé de l’univers U. La figure 11.1 illustre cette approche ; l’alvéole k pointe vers
un élément de l’ensemble ayant pour clé k. Si l’ensemble ne contient aucun élément
de clé k, alors T[k] = NIL.
T
0
U
(univers des clés)
0
6
9
7
4
1
2
K
(clés
réelles) 5
5
1
3
2
3
clé
données satellites
2
3
4
5
6
8
7
8
8
9
Figure 11.1 Implémentation d’un ensemble dynamique à l’aide d’une table à adressage direct T. Chaque clé de l’univers U = {0, 1, . . . , 9} correspond à un indice de la table. L’ensemble
K = {2, 3, 5, 8} des clés réelles détermine les alvéoles de la table qui contiennent des pointeurs
vers des éléments. Les autres alvéoles, en gris foncé, contiennent NIL.
11.1
Tables à adressage direct
217
L’implémentation des opérations de dictionnaire est triviale :
R ECHERCHER -A DRESSAGE -D IRECT(T, k)
retourner T[k]
I NSÉRER -A DRESSAGE -D IRECT(T, x)
T[clé[x]] ← x
S UPPRIMER -A DRESSAGE -D IRECT(T, x)
T[clé[x]] ← NIL
Chacune de ces opérations est rapide : un temps O(1) suffit.
Pour certaines applications, les éléments de l’ensemble dynamique peuvent se
trouver eux-mêmes dans la table à adressage direct. C’est-à-dire qu’au lieu de stocker la clé et les données satellite d’un objet en dehors de la table, avec un pointeur
partant d’une alvéole de la table et pointant vers l’objet, on peut conserver l’objet
entier dans l’alvéole, pour économiser de l’espace. Par ailleurs, il est souvent inutile
de conserver le champ clé de l’objet, puisque si l’on dispose de l’indice d’un objet
dans la table, on a aussi sa clé. Toutefois, si les clés ne sont pas conservées, il faut se
ménager un moyen de savoir si l’alvéole est vide.
Exercices
11.1.1 Soit un ensemble dynamique S, représenté par une table à adressage direct T de
longueur m. Décrire une procédure qui trouve l’élément maximal de S. Quelle est l’efficacité
de votre procédure dans le cas le plus défavorable ?
c Dunod – La photocopie non autorisée est un délit
11.1.2 Un vecteur de bits est tout simplement un tableau de bits (0 et 1). Un vecteur de
bits de longueur m prend beaucoup moins d’espace qu’un tableau de m pointeurs. Décrire
comment on pourrait utiliser un vecteur de bits pour représenter un ensemble dynamique
d’éléments distincts sans données satellites. Les opérations de dictionnaire devront s’exécuter
dans un temps O(1).
11.1.3 Donner une suggestion d’implémentation d’une table à adressage direct dans laquelle
les clés des éléments stockés ne sont pas nécessairement distinctes et où les éléments peuvent
comporter des données satellites. Les trois opérations de dictionnaire (I NSÉRER, S UPPRIMER
et R ECHERCHER) devront s’exécuter dans un temps O(1). (Ne pas oublier que S UPPRIMER
prend comme argument un pointeur vers un objet à supprimer, et non une clé.)
11.1.4 On souhaite implémenter un dictionnaire en utilisant l’adressage direct sur un
très grand tableau. Au départ, les entrées du tableau peuvent contenir des données quelconques ; l’initialisation complète du tableau s’avère peu pratique, à cause de sa taille. Décrire
un schéma d’implémentation de dictionnaire via adressage direct sur un très grand tableau.
Chaque objet stocké devra consommer un espace O(1) ; les opérations R ECHERCHER, I NSÉ RER et S UPPRIMER devront prendre chacune un temps O(1) ; et l’initialisation des structures
218
11 • Tables de hachage
de données devra se faire en un temps O(1). (Conseil : Utiliser une pile supplémentaire,
dont la taille est le nombre des clés effectivement stockées dans le dictionnaire, pour aider à
déterminer si un élément donné du grand tableau est valide ou non.)
11.2 TABLES DE HACHAGE
L’inconvénient de l’adressage direct est évident : si l’univers U est grand, gérer une
table T de taille |U| peut se révéler compliqué, voire impossible, compte tenu de
la mémoire généralement disponible dans un ordinateur. Par ailleurs, l’ensemble K
des clés réellement conservées peut être tellement petit comparé à U que la majeure
partie de l’espace alloué pour T est gaspillé.
Lorsque l’ensemble K des clés stockées dans un dictionnaire est beaucoup plus
petit que l’univers U de toutes les clés possibles, une table de hachage requiert moins
de place de stockage qu’une table à adressage direct. En particulier, les besoins en
espace de stockage peuvent être réduits à Q(|K|), bien que la recherche d’un élément
dans la table de hachage s’effectue toujours en O(1). (Le seul hic est que cette borne
concerne le temps moyen, alors que pour l’adressage direct, elle est valable pour le
temps le plus défavorable.)
Avec l’adressage direct, un élément de clé k est conservé dans l’alvéole k. Avec
le hachage, cet élément est stocké dans l’alvéole h(k) ; autrement dit, on utilise une
fonction de hachage h pour calculer l’alvéole à partir de la clé k. Ici, h établit une
correspondance entre l’univers U des clés et les alvéoles d’une table de hachage
T[0 . . m − 1] :
h : U → {0, 1, . . . , m − 1} .
On dit qu’un élément de clé k est haché dans l’alvéole h(k) ; on dit également que
h(k) est la valeur de hachage de la clé k. La figure 11.2 en illustre le principe. Le but
de la fonction de hachage est de réduire l’intervalle des indices de tableau à gérer. Au
lieu de |U| valeurs, il suffit de gérer m valeurs. Les besoins en stockage sont réduits
en conséquence.
L’inconvénient de cette idée est que deux clés peuvent être hachées vers la même
alvéole, entraînant ainsi une collision. Heureusement, il existe des techniques efficaces pour résoudre les conflits créés par les collisions.
Bien sûr, la solution idéale serait d’éviter complètement les collisions. On peut
essayer d’atteindre ce but en choisissant de façon pertinente la fonction de hachage
h. Une idée est de faire que h paraisse « aléatoire », ce qui permet d’éviter les
collisions ou au moins d’en limiter le nombre. Le terme même de « hacher » traduit
l’esprit de cette approche, en évoquant une découpe désordonnée en petits morceaux.
(Bien sûr, une fonction de hachage h doit être déterministe au sens où une entrée
donnée k doit toujours produire la même sortie h(k).) Toutefois, comme |U| > m,
il existe forcément au moins deux clés ayant la même valeur de hachage ; éviter
complètement les collisions est donc impossible. Bien qu’une fonction de hachage
11.2
Tables de hachage
219
T
0
U
(univers des clés)
k1
K
k4
(clés
réelles)
k2
k5
k3
h(k1)
h(k4)
h(k2) = h(k5)
h(k3)
m–1
Figure 11.2 Utilisation d’une fonction de hachage h pour faire correspondre les clés à des alvéoles d’une table de hachage. Les clés k2 et k5 correspondent à la même alvéole et entrent donc
en collision.
bien conçue, imitant « l’aléatoire », puisse minimiser le nombre de collisions, une
méthode de résolution des collisions est donc indispensable.
La suite de cette section aborde la technique de résolution des collisions la plus
simple, appelée chaînage. La section 11.4 introduit une autre méthode, appelée adressage ouvert.
a) Résolution des collisions par chaînage
c Dunod – La photocopie non autorisée est un délit
Avec le chaînage, on place dans une liste chaînée tous les éléments hachés vers la
même alvéole, comme le montre la figure 11.3. L’alvéole j contient un pointeur vers
la tête de liste de tous les éléments hachés vers j ; si aucun n’élément n’est présent,
l’alvéole j contient NIL.
Les opérations de dictionnaire sur une table de hachage T sont faciles à implémenter lorsque les collisions sont résolues par chaînage.
I NSÉRER -H ACHAGE -C HAÎNÉE(T, x)
insère x en tête de la liste T[h(clé[x])]
R ECHERCHER -H ACHAGE -C HAÎNÉE(T, k)
recherche un élément de clé k dans la liste T[h(k)]
S UPPRIMER -H ACHAGE -C HAÎNÉE(T, x)
supprime x de la liste T[h(clé[x])]
Le temps d’exécution de l’insertion est O(1) dans le cas le plus défavorable. La
procédure d’insertion est rapide, en partie parce qu’elle suppose que l’élément x en
cours d’insertion n’est pas déjà présent dans la table ; on peut, si nécessaire, vérifier
cette hypothèse (moyennant un coût supplémentaire) en procédant à une recherche
11 • Tables de hachage
220
T
U
(univers des clés)
k1
k4
k5
k2
k3
k8
k6
k1
K
(clés
réelles)
k4
k5
k7
k2
k6
k8
k3
k7
Figure 11.3 Résolution des collisions par chaînage. Chaque alvéole de la table de hachage
T[j] contient une liste chaînée de toutes les clés dont la valeur de hachage est j. Par exemple
h(k1 ) = h(k4 ) et h(k5 ) = h(k2 ) = h(k7 ).
avant insertion. Pour la recherche, le temps d’exécution du cas le plus défavorable est
proportionnel à la longueur de la liste ; nous analyserons cette opération plus précisément un peu plus loin. La suppression d’un élément x peut se faire en temps O(1) si
les listes sont doublement chaînées. (Notez que S UPPRIMER -H ACHAGE -C HAÎNÉE
prend en entrée un élément x et non sa clé k, de sorte qu’il n’est pas nécessaire de
commencer par chercher x. Si les listes étaient à chaînage simple, il ne serait pas
d’une grande aide de prendre en entrée l’élément x au lieu de la clé k. Il faudrait encore trouver x dans la liste T[h(clé[x])], de façon que le lien succ du prédécesseur de
x puisse être réinitialisé comme il faut et contourner x. En pareil cas, suppression et
recherche auraient fondamentalement le même temps d’exécution.)
b) Analyse du hachage avec chaînage
Quelle est l’efficacité du hachage avec chaînage ? En particulier, combien de temps
faut-il pour rechercher un élément dont on connaît la clé ?
Étant donnée une table de hachage T à m alvéoles qui stocke n éléments, on définit
pour T le facteur de remplissage a par n/m, c’est-à-dire le nombre moyen d’éléments
stockés dans une chaîne. Notre analyse se fera en fonction de a qui peut être inférieur,
égal ou supérieur à 1.)
Le comportement, dans le cas le plus défavorable, du hachage avec chaînage est
très mauvais : les n clés sont toutes hachées vers la même alvéole, y formant une liste
de longueur n. Le temps d’exécution de la recherche est alors Q(n) plus le temps de
calcul de la fonction de hachage ; pas mieux que si l’on avait utilisé une seule liste
chaînée pour tous les éléments. Manifestement, les tables de hachage ne sont pas
choisies pour leurs performances dans le cas le plus défavorable. (Le hachage parfait,
présenté à la section 11.5, fournit toutefois de bonnes performances pour le cas le
plus défavorable, quand l’ensemble des clés est statique.)
11.2
Tables de hachage
221
Les performances moyennes du hachage dépendent de la manière dont la fonction
de hachage h répartit en moyenne l’ensemble des clés à stocker parmi les m alvéoles.
La section 11.3 étudie les diverses possibilités, mais pour l’instant, on suppose que
chaque élément a la même chance d’être haché vers l’une quelconque des alvéoles,
indépendamment des endroits où les autres éléments sont allés. Cette hypothèse est
dite de hachage uniforme simple.
Pour j = 0, 1, . . . , m − 1, notons la longueur de la liste T[j] par nj , de sorte que
n = n0 + n1 + · · · + nm−1 ,
(11.1)
et la valeur moyenne de nj est E [nj ] = a = n/m.
On suppose que la valeur de hachage h(k) peut être calculée en temps O(1), de sorte
que le temps requis par la recherche d’un élément de clé k dépend linéairement de la
longueur de la liste T[h(k)]. En plus du temps O(1) requis par le calcul de la fonction
de hachage et l’accès à l’alvéole h(k), on considère le nombre attendu d’éléments
examinés par l’algorithme de recherche, c’est-à-dire le nombre d’éléments de la liste
T[h(k)] qui sont testés pour voir si leur clé est égale à k. On considèrera deux cas.
Dans le premier, la recherche échoue : aucun élément de la table ne possède la clé k.
Dans le second, la recherche permet de trouver un élément de clé k.
Théorème 11.1 Dans une table de hachage pour laquelle les collisions sont réso-
lues par chaînage, une recherche infructueuse prend un temps moyen Q(1 + a), sous
l’hypothèse d’un hachage uniforme simple.
c Dunod – La photocopie non autorisée est un délit
Démonstration : Si l’on suppose que le hachage est uniforme simple, toute clé k
à des chances égales d’être hachée vers l’une quelconque des m alvéoles. Le temps
moyen pour la recherche infructueuse d’une clé k est le temps moyen pour la poursuite de la recherche jusqu’à la fin de la liste T[h(k)], qui a une longueur moyenne
E [nh(k) ] = a. Donc, le nombre moyen d’éléments examinés dans une recherche infructueuse est a, et le temps total requis (y compris le temps de calcul de h(k)) est
Q(1 + a).
❑
La situation pour une recherche réussie est quelque peu différente, vu que chaque
liste n’a pas la même probabilité d’être examinée. Ici, la probabilité qu’une liste soit
examinée est proportionnelle au nombre de ses éléments. Néanmoins, le temps de
recherche moyen est encore Q(1 + a).
Théorème 11.2 Dans une table de hachage pour laquelle les collisions sont réso-
lues par chaînage, une recherche réussie prend en moyenne un temps Q(1 + a), sous
l’hypothèse d’un hachage uniforme simple.
Démonstration : On suppose que l’élément recherché a une probabilité égale d’être
l’un quelconque des n éléments stockés dans la table. Le nombre d’éléments examinés
lors d’une recherche fructueuse portant sur un élément x est égal à 1 plus le nombre
d’éléments qui apparaissent avant x dans la liste de x. Les éléments placés avant x dans
la liste ont tous été insérés après x, vu que les nouveaux éléments sont placés au début
11 • Tables de hachage
222
de la liste. Pour trouver le nombre moyen d’éléments examinés, on prend la moyenne
sur les n éléments x de la table, de 1 plus le nombre moyen d’éléments ajoutés à la
liste de x après que x a été ajouté à la liste. Soit xi le ième élément inséré dans la table,
avec i = 1, 2, . . . , n, et soit ki = clé[xi ]. Pour les clés ki et kj , on définit la variable
indicatrice Xij = I {h(ki ) = h(kj )}. En supposant qu’il y a hachage uniforme simple,
on a Pr {h(ki ) = h(kj )} = 1/m, et donc d’après le lemme 5.1, E [Xij ] = 1/m. Donc, le
nombre moyen d’éléments examinés dans une recherche réussie est
n
n
1
Xij
1+
E
n
i=1
j=i+1
=
=
1
E [Xij ]
1+
n
n
n
i=1
j=i+1
(d’après la linéarité de l’espérance)
1
1
1+
n
m
n
n
i=1
j=i+1
n
1 (n − i)
= 1+
nm
i=1
= 1+
1
nm
n
i=1
n−
n
i
i=1
1 2 n(n + 1) n −
(d’après l’équation (A.1))
nm
2
n−1
= 1+
2m
a
a
.
= 1+ −
2
2n
Ainsi, le temps total requis par une recherche réussie (y compris le temps pour calculer
la fonction de hachage) est Q(2 + a/2 − a/2n) = Q(1 + a).
❑
= 1+
Que signifie cette analyse ? Si le nombre d’alvéoles de la table de hachage est
au moins proportionnel au nombre d’éléments de la table, on a n = O(m) et, par
conséquent, a = n/m = O(m)/m = O(1). Donc, la recherche prend un temps constant
en moyenne. Comme l’insertion prend un temps O(1) dans le cas le plus défavorable,
et que la suppression prend un temps O(1) dans le cas le plus défavorable si les listes
sont doublement chaînées, toutes les opérations de dictionnaire peuvent donc se faire
en temps O(1) en moyenne.
Exercices
11.2.1 Supposons qu’on utilise une fonction de hachage h pour hacher n clés distinctes vers un tableau T de longueur m. Si le hachage est uniforme simple, quel est
le nombre moyen de collisions ? Plus précisément, quelle est la cardinalité attendue de
{(k, l) : k fi l et h(k) = h(l)} ?
11.3
Fonctions de hachage
223
11.2.2 Montrer comment on réalise l’insertion des clés 5, 28, 19, 15, 20, 33, 12, 17, 10 dans
une table de hachage où les collisions sont résolues par chaînage. On suppose que la table
contient 9 alvéoles et que la fonction de hachage est h(k) = k mod 9.
11.2.3 Le professeur Moriarty pense qu’on peut obtenir des gains de performance substantiels si l’on modifie le schéma de chaînage de telle manière que chaque liste soit triée. Comment la modification du professeur affecte-t-elle le temps d’exécution des recherches réussies,
des recherches infructueuses, des insertions et des suppressions ?
11.2.4 Proposer une façon d’allouer et libérer l’espace de stockage des éléments à l’intérieur
de la table elle-même en chaînant toutes les alvéoles inutilisées pour former une liste libre.
On supposera qu’une alvéole peut contenir un indicateur plus, soit un élément et un pointeur,
soit deux pointeurs. Toutes les opérations de dictionnaire et de liste libre devront s’exécuter
en temps O(1). La liste libre doit-elle être doublement chaînée, ou une chaîne simple suffitelle ?
11.2.5 Montrer que, si |U| > nm, il existe un sous-ensemble de U de taille n qui est constitué
de clés toutes hachées vers la même alvéole ; de sorte que le temps de recherche, dans le cas
le plus défavorable, pour le hachage avec chaînage est Q(n).
11.3 FONCTIONS DE HACHAGE
Dans cette section, nous étudierons certaines possibilités quant à la conception de
bonnes fonctions de hachage, puis nous présenterons trois schémas de création. Deux
de ces mécanismes, le hachage par division et le hachage par multiplication, sont heuristiques par nature, alors que le troisième mécanisme, le hachage universel, utilise la
randomisation pour offrir des performances bonnes et prouvables comme telles.
c Dunod – La photocopie non autorisée est un délit
a) Qu’est-ce qui fait une bonne fonction de hachage ?
Une bonne fonction de hachage vérifie (approximativement) l’hypothèse du hachage
uniforme simple : chaque clé a autant de chances d’être hachée vers l’une quelconque
des m alvéoles, indépendamment des endroits où sont allées les autres clés. Malheureusement, il est impossible en général de vérifier cette condition ; en effet, il est rare
que l’on connaisse la distribution de probabilité selon laquelle les clés sont tirées, et
les clés peuvent ne pas être tirées de façon indépendante.
Il peut advenir, à l’occasion, que l’on connaisse la distribution. Par exemple, supposons que les clés soient k nombres réels aléatoires, distribués indépendamment et
uniformément dans l’intervalle 0 k < 1. Dans ce cas, la fonction de hachage
h(k) = km
satisfait à la condition du hachage uniforme simple.
11 • Tables de hachage
224
En pratique, on peut souvent faire appel à des techniques heuristiques pour créer
une fonction de hachage efficace. On a parfois besoin, pour ce processus de conception, de connaître des informations qualitatives sur la distribution des clés. Considérons, par exemple, la table des symboles d’un compilateur, dans laquelle les clés sont
des chaînes de caractères arbitraires représentant les identificateurs d’un programme.
Il arrive souvent que des symboles proches, comme pt et pts, soient employés dans
le même programme. Une bonne fonction de hachage minimise le risque que ces
variantes se retrouvent dans la même alvéole.
Une approche courante est de former la valeur de hachage de manière à la rendre
indépendante des motifs (patterns) susceptibles d’exister dans les données. Par
exemple, la « méthode de la division » (étudiée plus loin) calcule la valeur de
hachage comme reste de la division de la clé par un nombre premier particulier. A
moins qu’il n’existe des relations entre ce nombre premier et des motifs figurant dans
la distribution des clés, cette méthode donne de bons résultats.
Finalement, on remarque que certaines applications des fonctions de hachage
risquent d’exiger des propriétés plus fortes que le hachage uniforme simple. Par
exemple, on pourrait souhaiter que les clés qui sont « proches » dans un certain sens
génèrent des valeurs de hachage très éloignées les unes des autres. (Cette propriété
est particulièrement souhaitable quand on utilise le sondage linéaire, défini à la
section 11.4.) Le hachage universel, présenté à la section 11.3.3, offre souvent les
propriétés souhaitées.
b) Clés interprétées comme des entiers naturels
La plupart des fonctions de hachage supposent que l’univers des clés est l’ensemble
N = {0, 1, 2, . . .} des entiers naturels. Donc, si les clés ne sont pas des entiers naturels, on doit trouver un moyen de les interpréter comme des entiers naturels. Par
exemple, une clé sous forme de chaîne de caractères peut être interprétée comme un
entier exprimé dans une base adaptée. Ainsi, l’identificateur pt pourrait être interprété comme la paire d’entiers décimaux (112, 116), puisque p = 112 et t = 116 dans
l’ensemble des caractères ASCII ; ensuite, en l’exprimant en base 128, pt devient
(112·128) + 116 = 14452. Le plus souvent, il est facile de trouver une méthode aussi
simple pour une application donnée quelconque, permettant d’interpréter chaque clé
comme un entier naturel (potentiellement grand). À partir de maintenant, nous supposerons que les clés sont des entiers naturels.
11.3.1 Méthode de la division
Dans la méthode de la division, pour créer des fonctions de hachage, on fait correspondre une clé k avec l’une des m alvéoles en prenant le reste de la division de k par
m. En d’autres termes, la fonction de hachage est
h(k) = k mod m .
11.3
Fonctions de hachage
225
Par exemple, si la table de hachage a une taille m = 12 et que la clé est k = 100,
alors h(k) = 4. Comme il ne demande qu’une seule opération de division, le hachage
par division est très rapide.
Lorsqu’on utilise la méthode de la division, on évite en général certaines valeurs de
m. Par exemple, m ne doit pas être une puissance de 2 car, si m = 2p , h(k) est constitué
des p bits de poids faible de k. À moins que l’on ne sache que tous les motifs des p
bits d’ordre inférieur sont équiprobables, il vaut mieux faire dépendre la fonction
de hachage de tous les bits de la clé. Comme l’exercice 11.3.3 vous demandera de le
montrer, choisir m = 2p −1 quand k est une chaîne de caractères interprétée en base 2p ,
risque d’être un choix malheureux, car la permutation des caractères de k ne modifie
pas sa valeur de hachage. Un nombre premier pas trop proche d’une puissance exacte
de 2 est souvent u bon choix pour m. Par exemple, supposons qu’on souhaite allouer
une table de hachage, avec résolution des collisions par chaînage, pour gérer environ
n = 2000 chaînes de caractères, avec des caractères sur 8 bits. L’examen de 3 éléments
en moyenne en cas de recherche infructueuse n’est pas catastrophique et on alloue
donc une table de hachage de taille m = 701. On prend le nombre 701 parce qu’il
est premier et proche de 2000/3, sans être proche d’une puissance de 2. Si l’on traite
chaque clé k comme un entier, notre fonction de hachage devient
h(k) = k mod 701 .
11.3.2 Méthode de la multiplication
La méthode de la multiplication pour créer des fonctions de hachage agit en deux
étapes. D’abord, on multiplie la clé k par une constante A de l’intervalle 0 < A < 1
et on extrait la partie décimale de kA. Ensuite, on multiplie cette valeur par m et on
prend la partie entière du résultat. En résumé, la fonction de hachage vaut
c Dunod – La photocopie non autorisée est un délit
h(k) = m (k A mod 1) ,
où « k A mod 1 » représente la partie décimale de kA, c’est-à-dire kA − kA .
Un avantage de la méthode par multiplication est que la valeur de m n’est pas
critique. On choisit en général une puissance de 2 (m = 2p pour un certain entier p)
ce qui permet d’implémenter facilement la fonction sur la plupart des ordinateurs
de la manière suivante : supposons que la taille du mot sur la machine concernée
soit w bits et que k tienne dans un seul mot. On limite A à une fraction de la forme
s/2w , où s est un entier de la plage 0 < s < 2w . En se référant à la figure 11.4, on
commence par multiplier k par l’entier à w bits s = A·2w . Le résultat est une valeur à
2w bits r1 2w + r0 , où r1 est le mot de poids fort du produit et r0 est le mot de poids
faible du produit. La valeur de hachage à p bits désirée se compose des p bits les plus
significatifs de r0 .
Bien que cette méthode fonctionne pour n’importe quelle valeur de la constante
A, elle fonctionne mieux pour certaines valeurs que pour d’autres. Le meilleur choix
11 • Tables de hachage
226
w bits
k
s = A.2w
×
r0
r1
extraction p bits
h(k)
Figure 11.4 La méthode de hachage par multiplication. La représentation w bits de la clé k est
multipliée par la valeur w bits s = A · 2w . Les p bits de poids faible de la moitié inférieure (w bits)
du produit forment la valeur de hachage désirée h(k).
dépend des caractéristiques des données à hacher. Knuth [185] suggère que
√
A ≈ ( 5 − 1)/2 = 0,6180339887 . . .
(11.2)
a de bonnes chances de bien marcher.
Par exemple, prenons k = 123456, p = 14, m = 214 = 16384 et w = 32. En
reprenant l’idée de Knuth,
s/232 qui
√ on choisit pour A la fraction de la forme 32
est la plus proche de ( 5 − 1)/2, de sorte que A = 2654435769/2 . Alors,
k·s = 327706022297664 = (76300·232 ) + 17612864, et donc r1 = 76300
et r0 = 17612864. Les 14 bits les plus significatifs de r0 donnent la valeur h(k) = 67.
11.3.3 Hachage universel
Si un ennemi choisit les clés à hacher via une fonction de hachage figée, il peut choisir n clés qui sont toutes hachées vers la même alvéole, ce qui entraîne un temps de
recherche de Q(n) en moyenne. Toute fonction de hachage fixée à l’avance est vulnérable à ce type de comportement de cas le plus défavorable ; le seul moyen efficace
d’améliorer cette situation est de choisir la fonction de hachage aléatoirement, c’està-dire indépendamment des clés qui vont être réellement stockées. Cette approche,
appelée hachage universel, donne de bonnes performances en moyenne, quelles que
soient les clés choisies par l’ennemi.
Le principe du hachage universel est de sélectionner la fonction de hachage au
hasard, à partir d’une classe de fonctions soigneusement conçue, au début de l’exécution. Comme pour le tri rapide, la randomisation garantit qu’aucune entrée particulière ne provoquera systématiquement le comportement du cas le plus défavorable.
A cause de la randomisation, l’algorithme peut se comporter différemment à chaque
exécution, même sur une entrée identique ; cette approche garantit de bonnes performances en moyenne, quelles que soient les clés fournies en entrée. Si l’on revient
à l’exemple de la table des symboles d’un compilateur, on s’aperçoit que le choix
du programmeur pour les identificateurs ne peut plus maintenant provoquer systématiquement de mauvaises performances de hachage. Ces mauvaises performances
11.3
Fonctions de hachage
227
n’apparaissent que si le compilateur choisit une fonction de hachage aléatoire qui
provoque le mauvais hachage de l’ensemble des identificateurs, mais la probabilité
pour que cela se produise est faible et est la même pour n’importe quel ensemble
d’identificateurs de la même taille.
Soit H une collection finie de fonctions de hachage qui créent une correspondance
entre un univers U de clés et l’intervalle {0, 1, . . . , m − 1}. On dit d’une telle collection qu’elle est universelle si, pour chaque paire de clés k, l ∈ U distinctes, le nombre
de fonctions de hachage h ∈ H pour lesquelles h(k) = h(l) vaut au plus |H| /m. Autrement dit, avec une fonction de hachage choisie aléatoirement dans H, les chances
de collision entre des clés distinctes k et l ne sont pas plus grandes que la chance 1/m
d’avoir une collision si h(k) et h(l) sont choisies aléatoirement et indépendamment
dans l’ensemble {0, 1, . . . , m − 1}.
Le théorème suivant montre qu’une classe universelle de fonctions de hachage
offre un bon comportement dans le cas moyen. Rappelons-nous que ni désigne la
longueur de la liste T[i].
Théorème 11.3 Supposons qu’une fonction de hachage h soit choisie dans une collection universelle de fonctions de hachages et utilisée pour hacher n clés vers une
table T de taille m, avec emploi du chaînage pour gérer les collisions. Si la clé k
n’est pas dans la table, alors la longueur moyenne E [nh(k) ] de la liste vers laquelle
la clé k est hachée est d’au plus a. Si la clé k est dans la table, alors la longueur
moyenne E [nh(k) ] de la liste contenant la clé k est d’au plus 1 + a.
Démonstration : Notez que les espérances ici sont calculées à partir du choix de
c Dunod – La photocopie non autorisée est un délit
la fonction de hachage, et qu’elles ne dépendent d’aucunes hypothèses concernant
la distribution des clés. Pour toute paire k et l de clés distinctes, on définit la variable indicatrice Xkl = I {h(k) = h(l)}. Comme, d’après la définition, une paire de clés
a une probabilité de collision d’au plus 1/m, on a Pr {h(k) = h(l)} 1/m, et donc le
lemme 5.1 entraîne que E [Xkl ] 1/m.
Définissons ensuite, pour chaque clé k, la variable aléatoire Yk qui est égale au nombre
de clés autres que k qui sont hachées vers l’alvéole de k, de sorte que
Xkl .
Yk =
l∈T
lfik
On a donc
E [Yk ] =
E
Xkl
l∈T
lfik
=
E [Xkl ]
(d’après la linéarité de l’espérance)
l∈T
lfik
1
.
m
l∈T
lfik
Le reste de la démonstration dépend de savoir si la clé k est dans la table T.
11 • Tables de hachage
228
– Si k ∈ T, alors nh(k) = Yk et |{l : l ∈ T et l fi k}| = n. Donc, E [nh(k) ] = E [Yk ]
n/m = a.
– Si k ∈ T, alors comme la clé k figure dans la liste T[h(k)] et que le compteur Yk
n’inclut pas la clé k, on a nh(k) = Yk + 1 et |{l : l ∈ T et l fi k}| = n − 1. Donc
E [nh(k) ] = E [Yk ] + 1 (n − 1)/m + 1 = 1 + a − 1/m < 1 + a.
❑
Le corollaire suivant dit que le hachage universel fournit le résultat désiré : il est
désormais impossible à un ennemi de sélectionner une suite d’opérations qui force le
temps d’exécution le plus défavorable. En randomisant intelligemment le choix de la
fonction de hachage au moment de l’exécution, on a la garantie que toute séquence
d’opérations pourra être traitée avec un bon temps attendu d’exécution.
Corollaire 11.4 En combinant hachage universel et gestion des collisions par chaînage dans une table à m alvéoles, il faut un temps moyen Q(n) pour traiter une suite
quelconque de n opérations I NSÉRER, R ECHERCHER et S UPPRIMER contenant O(m)
opérations I NSÉRER.
Démonstration : Comme le nombre d’insertions est O(m), on a n = O(m) et donc
a = O(1). Les opérations I NSÉRER et S UPPRIMER prennent un temps constant et,
d’après le théorème 11.3, le temps moyen de chaque opération R ECHERCHER est
O(1). D’après la linéarité de l’espérance, le temps moyen pour l’ensemble de la séquence d’opérations est donc O(n).
❑
a) Conception d’une classe universelle de fonctions de hachage
Il est assez simple de concevoir une classe universelle de fonctions de hachages,
comme nous le verrons en nous aidant d’un peu de théorie des nombres. Commencez
par relire le chapitre 31 si vous n’êtes pas très familier de la théorie des nombres.
On commence par choisir un nombre premier p assez grand pour que toute clé
possible k soit dans l’intervalle 0 à p − 1, bornes comprises. Soit Zp l’ensemble
{0, 1, . . . , p − 1}, et soit Z∗p l’ensemble {1, 2, . . . , p − 1}. Comme p est premier, on
peut résoudre les équations modulo p avec les méthodes vues au chapitre 31. Comme
on suppose que la taille de l’univers des clés est supérieure au nombre d’alvéoles de
la table de hachage, on a p > m.
Définissons maintenant la fonction de hachage ha,b pour tout a ∈ Z∗p et tout b ∈ Zp ,
en utilisant une transformation linéaire suivie de réductions modulo p puis modulo m :
ha,b (k) = ((ak + b) mod p) mod m .
(11.3)
Par exemple, avec p = 17 et m = 6, on a h3,4 (8) = 5. La famille de toutes ces fonctions
de hachages est
(11.4)
Hp,m = {ha,b : a ∈ Z∗p et b ∈ Zp } .
Chaque fonction de hachage ha,b définit une correspondances de Zp vers Zm . Cette
classe de fonctions de hachage présente l’intéressante propriété que la taille m de
l’intervalle de sortie est arbitraire (et pas forcément une valeur première) ; nous ferons
11.3
Fonctions de hachage
229
usage de cette caractéristique à la section 11.5. Comme il y a p − 1 choix pour a et p
choix pour b, il y a p(p − 1) fonctions de hachages de Hp,m .
Théorème 11.5 La classe Hp,m de fonctions de hachages définie par les équa-
tions (11.3) et (11.4) est universelle.
Démonstration : Soient deux clés distinctes k et l de Zp , de sorte que k fi l. Pour
une fonction de hachage donnée ha,b , soit
r
= (ak + b) mod p ,
s = (al + b) mod p .
Notez, pour commencer, que r fi s. Pourquoi ? Observez que
r − s ≡ a(k − l) (mod p) .
c Dunod – La photocopie non autorisée est un délit
Il s’ensuit que r fi s parce que p est premier et parce que a et (k − l) sont tous les
deux non nuls modulo p, ce qui fait que leur produit est forcément non nul modulo p
d’après le théorème 31.6. Par conséquent, lors du calcul d’un quelconque ha,b de Hp,m ,
à des entrées distinctes k et l sont associées des valeurs distinctes r et s modulo p ; il
n’y a pas de collisions, tout au moins au « niveau modulo p ». En outre, chacun des
p(p − 1) choix possibles pour la paire (a, b) avec a fi 0 donne une paire résultante
(r, s) différente avec r fi s, car on peut résoudre a et b à partir de r et s :
a =
((r − s)((k − l)−1 mod p)) mod p ,
b =
(r − ak) mod p ,
où ((k−l)−1 mod p) désigne l’inverse multiplicatif unique, modulo p, de k−l. Comme
il n’y a que p(p−1) paires possibles (r, s) avec r fi s, il existe une bijection entre paires
(a, b) avec a fi 0 et paires (r, s) avec r fi s. Donc, pour toute paire donnée d’entrées
k et l, si l’on choisit (a, b) aléatoirement de manière uniforme dans Z∗p × Zp , alors
la paire résultante (r, s) a la même probabilité d’être l’une quelconque des paires de
valeurs distinctes modulo p.
Il s’ensuit que la probabilité que des clés distinctes k et l entrent en collision est égale
à la probabilité que r ≡ s (mod m) quand r et s sont choisis aléatoirement comme
valeurs distinctes modulo p. Pour une valeur donnée de r, parmi les p − 1 autres
valeurs possible de s, le nombre de valeurs s telles que s fi r et s ≡ r (mod m) est au
plus
p/m − 1 ((p + m − 1)/m) − 1 (d’après l’inégalité (3.6))
=
(p − 1)/m .
La probabilité que s entre en collision avec r, après réduction modulo m, est au plus
égale à ((p − 1)/m)/(p − 1) = 1/m.
En conclusion, pour toute paire de valeurs distinctes k, l ∈ Zp ,
Pr {ha,b (k) = ha,b (l)} 1/m ,
et donc Hp,m est bien universelle.
❑
11 • Tables de hachage
230
Exercices
11.3.1 Supposons qu’on souhaite parcourir une liste chaînée de longueur n, dans laquelle
chaque élément contient une clé k en plus d’une valeur de hachage h(k). Chaque clé est une
chaîne de caractères longue. Comment pourrait-on tirer parti des valeurs de hachage pendant
la recherche d’un élément de clé donnée ?
11.3.2 Supposons qu’une chaîne de r caractères soit hachée dans m alvéoles en étant traitée
comme un nombre en base 128 auquel on applique la méthode de la division. Le nombre
m est facilement représenté par un mot mémoire sur 32 bits, mais la chaîne de r caractères,
traitée comme un nombre en base 128, occupe plusieurs mots. Comment peut-on appliquer
la méthode de la division pour calculer la valeur de hachage de la chaîne de caractères sans
utiliser plus qu’un nombre de mots constant pour le stockage, en dehors de la chaîne ellemême ?
11.3.3 On considère une variante de la méthode de la division dans laquelle h(k) = k mod m,
où m = 2p − 1 et k est une chaîne de caractères interprétée en base 2p . Montrer que si la
chaîne x peut être déduite de la chaîne y par permutation de ses caractères, alors x et y ont
même valeur de hachage. Donner un exemple d’application pour laquelle cette propriété de
la fonction de hachage serait indésirable.
11.3.4 On considère une table de hachage de taille m = 1000 et la fonction de hachage
√
h(k) = m (k A mod 1) pour A = ( 5 − 1)/2. Calculer les emplacements correspondant aux
clés 61, 62, 63, 64 et 65.
11.3.5 On dit qu’une famille H de fonctions de hachage reliant un ensemble fini U à un
ensemble fini B est ´-universelle si, pour toute paire d’éléments distincts k et l de U,
Pr {h(k) = h(l)} ´ ,
la probabilité étant défini par le tirage aléatoire de la fonction de hachage h dans la famille
H. Montrer qu’une famille ´-universelle de fonctions de hachage doit vérifier
´
1
1
−
.
|B| |U|
11.3.6 Soit U l’ensemble des n-uplets dont les valeurs sont tirées de Zp , et soit B = Zp ,
avec p premier. Définir la fonction de hachage hb : U → B pour b ∈ Zp sur un n-uplet en
entrée a0 , a1 , . . . , an−1 dans U, sous la forme
hb (a0 , a1 , . . . , an−1 ) =
n−1
aj bj
j=0
et soit H = {hb : b ∈ Zp }. Montrer que H est ((n − 1)/p)-universelle d’après la définition de
´-universelle de l’exercice 11.3.5. (Conseil : Voir exercice 31.4.4.)
11.4
Adressage ouvert
231
11.4 ADRESSAGE OUVERT
Dans l’adressage ouvert, tous les éléments sont conservés dans la table de hachage
elle-même. En d’autres termes, chaque entrée de la table contient soit un élément de
l’ensemble dynamique, soit NIL. Lorsqu’on recherche un élément, on examine systématiquement les alvéoles de la table jusqu’à trouver l’élément souhaité, ou jusqu’à
ce qu’on s’aperçoive que l’élément n’appartient pas à la table. Il n’existe ni listes ni
éléments conservés hors de la table, comme c’était le cas pour le chaînage. En adressage ouvert, la table de hachage peut donc se remplir entièrement, de telle manière
que plus aucune insertion ne soit possible ; le facteur de remplissage a ne peut jamais
être supérieur à 1.
Bien sûr, on pourrait conserver les listes chaînées servant au chaînage à l’intérieur de la table de hachage, dans les alvéoles inutilisées (voir exercice 11.2.4), mais
l’avantage de l’adressage ouvert est qu’il évite complètement le recours aux pointeurs. Au lieu de suivre des pointeurs, on calcule la séquence d’alvéoles à examiner. La mémoire supplémentaire libérée par la non conservation des pointeurs permet d’augmenter le nombre d’alvéoles de la table pour la même quantité de mémoire, ce qui engendre potentiellement moins de collisions et des recherches plus
rapides.
Pour effectuer une insertion à l’aide de l’adressage ouvert, on examine Successivement, on sonde comme on dit, la table de hachage jusqu’à ce qu’on trouve une alvéole
vide dans laquelle placer la clé. Au lieu de suivre l’ordre 0, 1, . . . , m−1 (qui demande
un temps de recherche Q(n)), la séquence des positions sondées dépend de la clé à
insérer. Pour déterminer les alvéoles à sonder, on étend la fonction de hachage pour
y inclure le nombre de sondages (en partant de 0) comme second argument. Ainsi, la
fonction de hachage devient
h : U × {0, 1, . . . , m − 1} → {0, 1, . . . , m − 1} .
c Dunod – La photocopie non autorisée est un délit
Avec l’adressage ouvert, il faut que pour chaque clé k, la séquence de sondage
h(k, 0), h(k, 1), . . . , h(k, m − 1)
soit une permutation de 0, 1, . . . , m − 1, de façon que chaque position de la table
de hachage finisse par être considérée comme une alvéole pour une nouvelle clé lors
du remplissage de la table. Dans le pseudo-code suivant, on suppose que les éléments
de la table de hachage T sont des clés dépourvues d’informations satellites ; la clé k
est identique à l’élément contenant la clé k. Chaque alvéole contient soit une clé, soit
NIL (si l’alvéole est vide).
232
11 • Tables de hachage
I NSÉRER -H ACHAGE(T, k)
1 i←0
2 répéter j ← h(k, i)
3
si T[j] = NIL
4
alors T[j] ← k
5
retourner j
6
sinon i ← i + 1
7
jusqu’à i = m
8 erreur « débordement de la table de hachage »
L’algorithme de recherche d’une clé k sonde la même séquence d’alvéoles que l’algorithme d’insertion étudié lorsqu’il fallait insérer la clé k. Du coup, la recherche peut
se terminer (par un échec) lorsque elle rencontre une alvéole vide, puisque k aurait
été inséré ici et pas plus loin dans la séquence de sondage. (Cette démonstration
suppose que les clés ne sont pas supprimées de la table de hachage.) La procédure
R ECHERCHER -H ACHAGE prend en entrée une table de hachage T et une clé k et elle
retourne j s’il s’avère que l’alvéole j contient la clé k, ou NIL si la clé k n’est pas
présente dans la table T.
R ECHERCHER -H ACHAGE(T, k)
1 i←0
2 répéter j ← h(k, i)
3
si T[j] = k
4
alors retourner j
5
i←i+1
6
jusqu’à T[j] = NIL ou i = m
7 retourner NIL
La suppression dans une table à adressage ouvert est difficile. Quand on supprime une
clé dans l’alvéole i, on ne peut pas se contenter de marquer l’alvéole comme étant
vide en y plaçant NIL : cela pourrait rendre impossible l’accès à une clé k durant
l’insertion de laquelle on aurait sondé l’alvéole i et trouvé qu’elle était occupée. Une
solution est de marquer l’alvéole en y stockant la valeur spéciale S UPPRIMÉE au lieu
de NIL. Nous pourrions alors modifier la procédure I NSÉRER -H ACHAGE pour traiter
ce type d’alvéoles comme s’il s’agissait d’alvéoles vides susceptibles de recevoir de
nouvelles clé. R ECHERCHER -H ACHAGE ne nécessite aucune modification, puisque
les valeurs S UPPRIMÉE sont sautées lors de la recherche. Toutefois, lorsqu’on procède de cette manière, les temps de recherche ne dépendent plus du facteur de remplissage a et on préfère en général choisir la technique de chaînage pour résoudre les
collisions lorsque des clés doivent être supprimées.
Dans notre analyse, nous faisons l’hypothèse d’un hachage uniforme : on suppose
que chacune des m! permutations possibles de {0, 1, . . . , m − 1} a autant de chances
de constituer la séquence de sondage de chaque clé. Le hachage uniforme généralise la notion de hachage uniforme simple définie précédemment aux contextes où
11.4
Adressage ouvert
233
la fonction de hachage ne produit pas un nombre unique, mais un séquence de sondage complète. Cela dit, le véritable hachage uniforme est difficile à implémenter et,
en pratique, on utilisera des approximations pertinentes (comme le double hachage,
défini plus loin).
Trois techniques sont souvent utilisées pour calculer les séquences de sondage
requises pour l’adressage ouvert : le sondage linéaire, le sondage quadratique et le
double hachage. Ces techniques garantissent toutes que h(k, 0), h(k, 1), . . . , h(k,
m − 1) est une permutation de 0, 1, . . . , m − 1 pour chaque clé k. Toutefois, aucune
de ces techniques ne vérifie entièrement l’hypothèse du hachage uniforme, car aucune
d’elles n’est capable de générer plus de m2 séquences de sondage différentes (au lieu
des m! requises par le hachage uniforme). Le double hachage détient le plus grand
nombre de séquences de sondage et, comme on pouvait s’y attendre, semble donner
les meilleurs résultats.
a) Sondage linéaire
Étant donnée une fonction de hachage ordinaire h : U → {0, 1, . . . , m − 1}, appelée
ici fonction de hachage auxiliaire, la méthode du sondage linéaire utilise la fonction
de hachage
h(k, i) = (h (k) + i) mod m
c Dunod – La photocopie non autorisée est un délit
pour i = 0, 1, . . . , m − 1. Étant donnée une clé k, la première alvéole sondée est
T[h (k)], c’est-à-dire l’alvéole donnée par la fonction de hachage auxiliaire. On sonde
ensuite l’alvéole T[h (k) + 1] et ainsi de suite jusqu’à l’alvéole T[m − 1]. Puis, on
revient aux alvéoles T[0], T[1], . . ., jusqu’à sonder finalement l’alvéole T[h (k) − 1].
Comme le sondage initial détermine la séquence de sondage toute entière, le sondage
linéaire n’utilise que m séquences de sondage distinctes.
Le sondage linéaire est facile à implémenter, mais il souffre d’un problème dit
grappe première. De longues suites d’alvéoles occupées se créent, augmentant ainsi
le temps de recherche moyen. Il se forme des grappes, puisqu’une alvéole vide précédée de i alvéoles pleines sera la prochaine à être remplie avec une probabilité de
(i + 1)/m. Les suites d’alvéoles occupées tendent à s’allonger et le temps moyen de
recherche à augmenter.
b) Sondage quadratique
Le sondage quadratique fait appel à une fonction de hachage de la forme
h(k, i) = (h (k) + c1 i + c2 i2 ) mod m ,
(11.5)
où h est une fonction de hachage auxiliaire, c1 et c2 fi 0 sont des constantes auxiliaires et i = 0, 1, . . . , m − 1. La première position sondée est T[h (k)] ; les positions
suivantes sont décalées selon des valeurs qui forment une fonction quadratique du
numéro de sondage i. Cette méthode fonctionne beaucoup mieux que le sondage linéaire, mais pour utiliser complètement la table de hachage, les valeurs de c1 , c2 et
11 • Tables de hachage
234
m sont imposées. (Le problème 11.3 montrera une façon de choisir ces paramètres).
De plus, si la première position de sondage est la même pour deux clés quelconques,
alors leurs séquences de sondage sont les mêmes, puisque h(k1 , 0) = h(k2 , 0) implique
h(k1 , i) = h(k2 , i). Cela conduit à une forme bénigne de la maladie de la grappe, appelée grappe secondaire. Comme pour le sondage linéaire, le sondage initial détermine
la séquence toute entière, de sorte que l’on n’a besoin que de m séquences de sondage
distinctes.
c) Double hachage
Le double hachage est l’une des meilleures méthodes connues pour l’adressage ouvert, car les permutations générées ont nombre des caractéristiques des permutations
choisies aléatoirement. Le double hachage utilise une fonction de hachage de la
forme
h(k, i) = (h1 (k) + ih2 (k)) mod m ,
où h1 et h2 sont des fonctions de hachage auxiliaires. La position initiale de sondage
est T[h1 (k)] ; les positions de sondage suivantes s’obtiennent par décalage à partir
des positions précédentes, décalage valant h2 (k) modulo m. Ainsi, contrairement au
cas des sondages linéaire ou quadratique, la séquence de sondage dépend ici de deux
manières de la clé k, puisque la position initiale, le décalage, ou les deux, peuvent
varier. La figure 11.5 donne un exemple d’insertion par double hachage.
0
1
2
3
4
5
6
7
8
9
10
11
12
79
69
98
72
14
50
Figure 11.5 Insertion par double hachage. Nous avons ici une table de hachage de taille 13 avec
h1 (k) = k mod 13 et h2 (k) = 1 + (k mod 11). Puisque 14 ≡ 1 mod 13 et 14 ≡ 3 mod 11, la clé 14
sera insérée dans l’alvéole vide 9, après que les alvéoles 1 et 5 auront été examinées et trouvées
occupées.
Le nombre h2 (k) doit être premier avec la taille m de la table de hachage pour que
la table soit parcourue entièrement. (Voir exercice 11.4-3.) Un moyen commode de
11.4
Adressage ouvert
235
garantir cette condition est de prendre pour m une puissance de 2 et de concevoir h2
pour qu’elle produise toujours un nombre impair. Une autre possibilité est de choisir
m premier et de concevoir h2 pour qu’elle retourne toujours un entier positif inférieur
à m. Par exemple, on pourrait prendre m premier et avoir
h1 (k) = k mod m ,
h2 (k) = 1 + (k mod m ) ,
où m est pris légèrement inférieur à m (disons, m − 1). Par exemple, si k = 123456,
m = 701 et m = 700, on a h1 (k) = 80 et h2 (k) = 257, ce qui donne 80 comme première
position de sondage et permet ensuite d’examiner une alvéole sur 257 (modulo m)
jusqu’à ce que la clé soit trouvée ou que chaque alvéole ait été examinée.
Le double hachage représente une amélioration par rapport aux sondages linéaire
et quadratique, au sens ou Q(m2 ) séquences de sondage sont utilisées au lieu de Q(m),
puisque chaque paire (h1 (k), h2 (k)) possible engendre une séquence de sondage distincte. Ainsi, les performances du double hachage semblent très proches de celle du
schéma « idéal » du hachage uniforme.
c Dunod – La photocopie non autorisée est un délit
d) Analyse de l’adressage ouvert
Notre analyse de l’adressage ouvert, comme celle du chaînage, s’exprime en fonction
du facteur de remplissage a = n/m de la table de hachage, quand n et m tendent vers
l’infini. Bien sûr, avec l’adressage ouvert, on a au plus un élément par alvéole et donc
n m, ce qui implique a 1.
On suppose qu’on utilise un hachage uniforme. Dans ce schéma idéalisé, la séquence de sondage h(k, 0), h(k, 1), . . . , h(k, m − 1) employée pour insérer ou rechercher chaque clé k la même probabilité d’être l’une quelconque des permutations
de 0, 1, . . . , m − 1. Bien sûr, une clé donnée est associée à une séquence de sondage
unique ; ce qu’on entend par là est que, si l’on considère la distribution de probabilité sur l’espace des clés et l’action de la fonction de hachage sur les clés, toutes les
séquences de sondage possibles sont équiprobables.
Nous allons maintenant analyser le nombre attendu de sondages pour le hachage
par adressage ouvert dans l’hypothèse d’un hachage uniforme, en commençant par
une analyse du nombre de sondages effectués lors d’une recherche infructueuse.
Théorème 11.6 Étant donnée une table de hachage pour adressage ouvert avec un
facteur de remplissage a = n/m < 1, le nombre moyen de sondages pour une recherche infructueuse vaut au plus 1/(1 − a), si l’on suppose que le hachage est
uniforme.
Démonstration : Lors d’une recherche infructueuse, chaque sondage hormis le
dernier accède à une alvéole occupée qui ne contient pas la clé voulue et la dernière alvéole sondée est vide. Définissons la variable aléatoire X comme étant le
nombre de sondages faits au cours d’une recherche infructueuse, et définissons l’événement Ai , pour i = 1, 2, . . ., comme étant l’événement dans lequel il y a un ième
11 • Tables de hachage
236
sondage qui détecte une alvéole occupée. Alors, l’événement {X i} est l’intersection des événements A1 ∩ A2 ∩ · · · ∩ Ai−1 . Nous allons borner Pr {X i} en bornant
Pr {A1 ∩ A2 ∩ · · · ∩ Ai−1 }. D’après l’exercice C.26,
Pr {A1 ∩ A2 ∩ · · · ∩ Ai−1 } = Pr {A1 } · Pr {A2 | A1 } · Pr {A3 | A1 ∩ A2 } · · ·
Pr {Ai−1 | A1 ∩ A2 ∩ · · · ∩ Ai−2 } .
Comme il y a n éléments et m alvéoles, Pr {A1 } = n/m. Pour j > 1, la probabilité qu’il
y ait un jème sondage qui détecte une alvéole occupée, sachant que les j − 1 premiers
sondages avaient détecté des alvéoles occupées, est (n − j + 1)/(m − j + 1). Cette
probabilité s’explique ainsi : on est en train de trouver l’un des (n − (j − 1)) éléments
restants dans l’une des (m − (j − 1)) alvéoles non examinées ; or, d’après l’hypothèse
de hachage uniforme, la probabilité est le rapport de ces quantité. En observant que
n < m entraîne que (n − j)/(m − j) n/m pour tout j tel qu 0 j < m, on a pour tout
i tel que 1 i m,
Pr {X i}
n−i+2
n n−1 n−2
·
·
···
m m−1 m−2
m−i+2
n i−1
m
= ai−1 .
=
Nous utilisons maintenant l’équation (C.24) pour borner le nombre attendu de sondages :
E [X]
=
∞
i=1
∞
Pr {X i}
ai−1
i=1
=
∞
ai
i=0
=
1
.
1−a
❑
La borne précédente 1+a+a2 +a3 +· · · a une interprétation intuitive. Il y a toujours
au moins un sondage. Avec une probabilité d’environ a, le premier sondage trouve
une alvéole occupée, ce qui justifie alors un second sondage. Avec une probabilité
d’environ a2 , les deux premières alvéoles sondées sont occupées, de sorte qu’il faut
un troisième sondage, etc.
Si a est une constante, le théorème 11.6 prédit qu’une recherche infructueuse
s’exécute en temps O(1). Par exemple, si la table de hachage est à moitié pleine, le
nombre moyen de sondages d’une recherche infructueuse est au plus 1/(1 − 0,5) = 2.
Si elle est remplie à 90%, le nombre moyen de sondages vaut au plus 1/(1−0,9) = 10.
Le théorème 11.6 donne les performances de la procédure I NSÉRER -H ACHAGE
presque immédiatement.
11.4
Adressage ouvert
237
Corollaire 11.7 Insérer un élément dans une table de hachage pour adressage ouvert
dotée d’un facteur de remplissage a demande au plus 1/(1−a) sondages en moyenne,
dans l’hypothèse où il y a hachage uniforme.
Démonstration : Un élément n’est inséré que s’il y a de la place dans la table, et
donc que si a < 1. L’insertion d’une clé équivaut à une recherche infructueuse, suivie
du placement de la clé dans la première alvéole trouvée. Donc, le nombre attendu de
sondages vaut au plus 1/(1 − a).
❑
Le calcul du nombre attendu de sondages pour une recherche réussie demande un
peu plus de travail.
Théorème 11.8 Étant donnée une table de hachage pour adressage ouvert ayant un
facteur de remplissage a < 1, le nombre moyen de sondages pour une recherche
fructueuse vaut au plus
1
1
ln
,
a 1−a
en supposant que le hachage est uniforme et que chaque clé de la table a autant de
chance d’être recherchée que les autres.
Démonstration : La recherche d’une clé k suit la même séquence de sondage que
celle utilisée pour insérer l’élément de clé k. D’après le corollaire 11.7, si k était la
(i + 1)ième clé insérée dans la table de hachage, le nombre attendu de sondages effectués lors d’une recherche de la clé k vaut au plus 1/(1 − i/m) = m/(m − i). Si l’on
fait la moyenne sur les n clés de la table de hachage, on obtient le nombre moyen de
sondages lors d’une recherche fructueuse :
1 m
n
m−i
=
m 1
n
m−i
=
1
(Hm − Hm−n ) ,
a
n−1
n−1
c Dunod – La photocopie non autorisée est un délit
i=0
i=0
i
où Hi = j=1 1/j est le ième nombre harmonique (tel que défini dans l’équation (A.7)).
En utilisant la technique décrite à la section A.2 consistant à borner une sommation
par une intégrale, on obtient
m
1
1 (Hm − Hm−n ) =
1/k
a
a
k=m−n+1
1 m
(1/x) dx (d’après l’inégalité (A.12))
a m−n
m
1
ln
=
a m−n
1
1
ln
=
a 1−a
comme borne pour le nombre attendu de sondages lors d’une recherche fructueuse. ❑
11 • Tables de hachage
238
Si la table de hachage est à moitié remplie, le nombre attendu de sondages lors
d’une recherche fructueuse est inférieur à 1,387. Si la table de hachage est remplie à
90 %, ce nombre moyen est inférieur à 2,559.
Exercices
11.4.1 On considère l’insertion des clés 10, 22, 31, 4, 15, 28, 17, 88, 59 dans une table de
hachage de longueur m = 11 en utilisant l’adressage ouvert avec pour fonction de hachage
auxiliaire h (k) = k mod m. Illustrer le résultat de l’insertion de ces clés par sondage linéaire,
par sondage quadratique avec c1 = 1 et c2 = 3, et par double hachage avec h2 (k) = 1 + (k mod
(m − 1)).
11.4.2 Écrire un pseudo code pour la procédure S UPPRIMER -H ACHAGE telle que définie
dans le texte, et modifier I NSÉRER -H ACHAGE pour qu’elle gère la valeur spéciale S UPPRI MÉE.
11.4.3 Supposons que l’on utilise le double hachage pour gérer les collisions ; autrement
dit, on utilise la fonction de hachage h(k, i) = (h1 (k) + ih2 (k)) mod m. Montrer que, si m et
h2 (k) ont un plus grand commun diviseur d 1 pour une certaine clé k, alors une recherche
infructueuse de la clé k examine (1/d) % de la table de hachage avant de revenir à l’alvéole
h1 (k). Donc, quand d = 1, auquel cas m et h2 (k) sont premiers entre eux, la recherche risque
de balayer toute la table de hachage. (Conseil : Voir chapitre 31).
11.4.4 On considère une table de hachage à adressage ouvert avec hachage uniforme. Donner des majorants pour le nombre moyen de sondages par recherche infructueuse et pour le
nombre attendu de sondages par recherche fructueuse, pour un facteur de remplissage égal à
3/4 puis à 7/8.
11.4.5 On considère une table de hachage à adressage ouvert ayant un facteur de remplissage a. Trouver la valeur non nulle a pour laquelle le nombre moyen de sondages dans une
recherche infructueuse vaut deux fois le nombre moyen de sondages dans une recherche fructueuse. Utiliser les majorants donnés par les théorèmes 11.6 et 11.8 concernant ces nombres
moyens de sondages.
11.5 HACHAGE PARFAIT
Le hachage s’utilise le plus souvent pour ses excellentes performances moyennes,
mais il donne aussi des performances excellentes dans le cas le plus défavorable
quand l’ensemble des clés est statique : une fois les clés stockées dans la table, l’ensemble des clés ne change plus. Certaines applications gèrent des ensembles statiques
de clés : ensemble des mots réservés d’un langage de programmation ; ensemble des
noms de fichier sur un CD-ROM ; etc. On dit d’une technique de hachage que c’est
un hachage parfait si le nombre d’accès mémoire requis pour faire une recherche
est, dans le cas le plus défavorable, O(1).
11.5
Hachage parfait
239
L’idée fondamentale pour créer un mécanisme de hachage parfait est simple. On
utilise une stratégie de hachage à deux niveaux, avec un hachage universel à chaque
niveau. La figure 11.6 illustre cette démarche.
Le premier niveau est essentiellement le même que pour le hachage avec chaînage : les n clés sont hachées vers m alvéoles, à l’aide d’une fonction de hachage h
soigneusement sélectionnée dans une famille de fonctions de hachage universelles.
Mais au lieu de créer une liste des clés dont le hachage a abouti à l’alvéole j,
on utilise une petite table de hachage secondaire Sj ayant une fonction de hachage
associée hj . En choisissant avec soin les fonctions de hachage hj , on peut garantir
qu’il n’y aura pas de collisions au niveau secondaire.
Pour assurer qu’il n’y aura pas de collisions au niveau secondaire, nous devons
cependant faire en sorte que la taille mj de la table de hachage Sj soit le carré du
nombre nj de clés hachées vers l’alvéole j. Ce genre de dépendance quadratique entre
mj et nj peut sembler de nature à engendrer des besoins excessifs en matière de capacité globale de stockage ; mais nous montrerons que, si l’on choisit bien la fonction de hachage du premier niveau, la quantité totale attendue d’espace est encore
O(n).
T
0
1
2
S2
m2 a2 b2 0
4 10 18 60 75
3
0
4
S5
5
6
7
8
c Dunod – La photocopie non autorisée est un délit
S
m0 a0 b0 0
1 0 0 10
1
2
3
m5 a5 b5
1 0 0 70
S7
m7 a7 b7 0
9 23 88 40
0
37
1
2
3
22
4
5
6
7
8
Figure
11.6
Utilisation
du
hachage
parfait
pour
stocker
l’ensemble
K = {10, 22, 37, 40, 60, 70, 75}. La fonction de hachage extérieure est h(k) = ((ak + b) mod
p) mod m, avec a = 3, b = 42, p = 101 et m = 9. Par exemple, h(75) = 2, et donc la clé 75 est
hachée vers l’alvéole 2 de la table T. Une table de hachage secondaire Sj stocke toutes les clés
hachées vers l’alvéole j. La taille de la table de hachage Sj est mj , et la fonction de hachage
associée est hj (k) = ((aj k + bj ) mod p) mod mj . Comme h2 (75) = 1, la clé 75 est stockée dans
l’alvéole 1 de la table de hachage secondaire S2 . Comme il n’y a pas de collisions dans aucune
des tables de hachage secondaires, la recherche prend un temps constant dans le cas le plus
défavorable.
Nous utilisons des fonctions de hachage prises dans les classes universelles de
fonctions de hachage de la section 11.3.3. La fonction de hachage de premier niveau
est choisie dans la classe Hp,m où, comme à la section 11.3.3, p est un nombre premier plus grand que toutes les valeurs de clé. Les clés hachées vers l’alvéole j sont
11 • Tables de hachage
240
rehachées vers une table de hachage secondaire Sj de taille mj , via une fonction de
hachage hj choisie dans la classe Hp,mj . (1)
Nous allons procéder en deux temps. Primo, nous allons voir comment garantir
que les tables secondaires n’aient pas de collisions. Secundo, nous allons montrer
que la quantité attendue de mémoire utilisée en tout (table de hachage principale,
plus toutes les tables de hachage secondaires) est O(n).
Théorème 11.9 Si l’on stocke n clés dans une table de hachage de taille m = n2
via une fonction de hachage h choisie aléatoirement dans une classe universelle de
fonctions de hachage, alors la probabilité d’avoir des collisions est inférieure à 1/2.
Démonstration : Il y a
!n"
2 paires de clés susceptibles d’entrer en collision ; chaque
paire présente une probabilité de collision de 1/m si h est choisie aléatoirement dans
une famille universelle H de fonctions de hachage. Soit X une variable aléatoire qui
compte le nombre de collisions. Quand m = n2 , le nombre attendu de collisions est
E [X]
=
n
1
·
2 n2
=
n2 − n 1
· 2
2
n
1/2 .
<
(Notez que cette analyse ressemble à celle du paradoxe des anniversaires, vu à la
section 5.4.1.) En appliquant l’inégalité de Markov (C.29), Pr {X t} E [X] /t,
avec t = 1 on termine la démonstration.
❑
Dans la situation décrite dans le théorème 11.9, où m = n2 , il s’ensuit qu’une
fonction de hachage h choisie aléatoirement dans H a plus de chances de ne pas avoir
de collisions que d’en avoir. Étant donné l’ensemble K des n clés à hacher (rappelezvous que K est statique), il est donc facile de trouver une fonction de hachage h sans
collision avec quelques essais aléatoires.
Cependant, quand n est grand, une table de hachage de taille m = n2 est excessive.
On adopte donc une stratégie de hachage à deux niveaux, et l’on utilise la démarche
du théorème 11.9 uniquement pour hacher les éléments au sein de chaque alvéole.
Une fonction de hachage h extérieure (de premier niveau) sert à hacher les clés vers
m = n alvéoles. Ensuite, si nj clés sont hachées vers l’alvéole j, une table de hachage
secondaire Sj de taille mj = n2j permet de faire des consultations en temps constant et
sans risque de collision.
Attaquons-nous maintenant au problème de garantir que la mémoire globalement
utilisée est O(n). Comme la taille mj de la jème table de hachage secondaire croît de
façon quadratique avec le nombre nj de clés stockées, il y a risque que la quantité
totale de mémoire soit excessive.
(1) Quand nj = mj = 1, on n’a pas vraiment besoin de fonction de hachage pour l’alvéole j ; quand on choisit
une fonction de hachage ha,b (k) = ((ak + b) mod p) mod mj pour une telle alvéole, on se contente de faire
a = b = 0.
11.5
Hachage parfait
241
Si la taille de la table de premier niveau est m = n, alors la quantité de mémoire
consommée est O(n) pour la table de hachage principale, pour le stockage des tailles
mj des tables de hachage secondaires et pour le stockage des paramètres aj et bj
définissant les fonctions de hachage secondaires hj extraites de la classe Hp,mj de la
section 11.3.3 (sauf quand nj = 1 et que l’on fait a = b = 0). Le théorème suivant et
un corollaire fournissent une borne pour les tailles combinées attendues de toutes les
tables de hachage secondaires. Un second corollaire borne la probabilité que la taille
combinée de toutes les tables de hachage secondaires soit supra linéaire.
Théorème 11.10 Si on stocke n clés dans une table de hachage de taille m = n via une
fonction de hachage h choisie aléatoirement dans une classe universelle de fonctions
de hachage, alors
m−1 E
n2j < 2n ,
j=0
où nj est le nombre de clés hachées vers l’alvéole j.
Démonstration : Nous commencerons par l’identité suivante, vérifiée pour tout
entier non négatif a :
a2 = a + 2
a
.
2
(11.6)
On a
E
m−1
n2j
j=0
= E
m−1
j=0
nj
nj + 2
2
(d’après l’équation (11.6))
m−1
m−1 nj
nj + 2 E
= E
(d’après la linéarité de l’espérance)
2
c Dunod – La photocopie non autorisée est un délit
j=0
j=0
m−1
nj
= E [n] + 2 E
2
(d’après l’équation (11.1))
j=0
m−1
nj
= n + 2E
2
j=0
(car n n’est pas une variable aléatoire) .
m−1 ! "
Pour évaluer la somme j=0 n2j , observons que c’est en fait le nombre total de
collisions. D’après les propriétés du hachage universel, la valeur attendue de cette
somme est au plus
n 1 n(n − 1) n − 1
=
=
,
2 m
2m
2
11 • Tables de hachage
242
car m = n. D’où,
E
m−1
n2j
n+2
j=0
n−1
= 2n − 1 < 2n .
2
❑
Corollaire 11.11 Si l’on stocke n clés dans une table de hachage de taille m = n, via
une fonction de hachage h choisie aléatoirement dans une classe universelle de fonctions de hachage, et si l’on prend pour chaque table de hachage secondaire une taille
mj = n2j (pour j = 0, 1, . . . , m − 1), alors la quantité moyenne de mémoire requise
par toutes les tables de hachage secondaires d’une stratégie de hachage parfait est
inférieure à 2n.
Démonstration : Puisque mj = n2j pour j = 0, 1, . . . , m − 1, le théorème 11.10 donne
E
m−1
mj = E
j=0
m−1
n2j < 2n ,
(11.7)
j=0
❑
ce qui termine la démonstration.
Corollaire 11.12 Si l’on stocke n clés dans une table de hachage de taille m = n,
via une fonction de hachage h choisie aléatoirement dans une classe universelle de
fonctions de hachage, et si l’on prend pour chaque table de hachage secondaire une
taille mj = n2j (pour j = 0, 1, . . . , m − 1), alors la probabilité que l’espace total
consommé pour les tables de hachage secondaires dépasse 4n est inférieure à 1/2.
Démonstration :
On applique derechef l’inégalité de Markov (C.29),
m−1
Pr {X t} E [X] /t, cette fois pour l’inégalité (11.7), avec X = j=0 mj et t = 4n :
Pr
m−1
j=0
)
mj 4n
E
m−1
j=0
4n
mj
<
2n
= 1/2 .
4n
❑
D’après le corollaire 11.12, on voit que, si l’on teste un petit nombre de fonctions
de hachage choisies aléatoirement dans la famille universelle, on obtient très vite une
fonction qui consomme un volume raisonnable de mémoire.
Exercices
11.5.1 Supposons que l’on insère n clés dans une table de hachage de taille m, via adressage ouvert et hachage uniforme. Soit p(n, m) la probabilité qu’il n’y ait pas de collisions.
Montrer que p(n, m) e−n(n−1)/2m . (Conseil : Voir équation (3.11).) Prouver que, quand n
√
dépasse m, la probabilité d’éviter les collisions tend rapidement vers zéro.
Problèmes
243
PROBLÈMES
11.1. Borne de plus long sondage pour hachage
Une table de hachage de taille m sert à stocker n éléments, avec n m/2. Les collisions sont résolues par adressage ouvert.
a. En faisant l’hypothèse d’un hachage uniforme, montrer que pour i = 1, 2, . . . , n,
la probabilité pour que la ième insertion nécessite plus de k sondages vaut au plus
2−k .
b. Montrer que, pour i = 1, 2, . . . , n, la probabilité pour que la ième insertion nécessite plus de 2 lg n sondages est au plus 1/n2 .
Soit la variable aléatoire Xi représentant le nombre de sondages requis par la ième
insertion. On a montré dans la partie (b) que Pr {Xi > 2 lg n} 1/n2 . Soit la variable
aléatoire X = max1 i n Xi représentant le nombre maximal de sondages requis par
l’une quelconque des n insertions.
c. Montrer que Pr {X > 2 lg n} 1/n.
d. Montrer que la longueur attendue E [X] de la séquence de sondage la plus longue
est O(lg n).
c Dunod – La photocopie non autorisée est un délit
11.2. Borne de la taille d’une alvéole pour chaînage
On suppose qu’on a une table de hachage avec n alvéoles et que les collisions sont
résolues par chaînage, et on suppose que n clés sont insérées dans la table. Chaque clé
a des chances égales d’être hachée vers chaque alvéole. Soit M le nombre maximal
de clés dans une alvéole quelconque, après que toutes les clés ont été insérées. Votre
mission est de prouver un majorantO(lg n/ lg lg n) sur E [M], espérance de M.
a. Montrer que la probabilité Qk pour que k clés exactement soient hachées vers une
alvéole particulière est donnée par la formule
1 k 1 n−k n
1−
.
Qk =
n
n
k
b. Soit Pk la probabilité pour que M = k, c’est-à-dire pour que l’alvéole contenant le
plus de clés en contienne k. Montrer que Pk nQk .
c. Utiliser la formule de Stirling (équation (3.17)) pour montrer que Qk < ek /kk .
d. Si l’on définit k0 = c lg n/ lg lg n, montrer qu’il existe une constante c > 1 telle
que Qk0 < 1/n3 . En déduire que Pk < 1/n2 pour k k0 = c lg n/ lg lg n.
e. Montrer que
*
*
c lg n
c lg n
c lg n
.
·n + Pr M ·
E [M] Pr M >
lg lg n
lg lg n
lg lg n
En déduire que E [M] = O(lg n/ lg lg n).
11 • Tables de hachage
244
11.3. Sondage quadratique
Supposons qu’on doive rechercher une clé k dans une table de hachage comportant
les emplacements 0, 1, . . . , m − 1 et qu’on dispose d’une fonction de hachage h établissant une correspondance entre l’espace des clés et l’ensemble {0, 1, . . . , m − 1}.
Le schéma de recherche est le suivant.
1) Calcul de la valeur i ← h(k) et initialisation de j ← 0.
2) Sondage de la position i pour la clé k désirée. En cas de succès, ou si la position
est vide, stopper la recherche.
3) Initialisation de j ← (j + 1) mod m et i ← (i + j) mod m, et retourner à l’étape 2.
On suppose que m est une puissance de 2.
a. Montrer que ce schéma est un cas particulier du schéma général du « sondage
quadratique », en exhibant les constantes c1 et c2 idoines pour l’équation (11.5).
b. Démontrer que, dans le cas le plus défavorable, cet algorithme examine chaque
position de la table.
11.4. Hachage k-universel et authentification
Soit H = {h} une classe de fonctions de hachage dans laquelle chaque fonction h
établit une correspondance entre l’univers U des clés et {0, 1, . . . , m − 1}. On dit que
H est k-universelle si, pour toute séquence fixée de k clés distinctes x1 , x2 , . . . , xk et pour toute fonction h choisie au hasard dans H, la séquence h(x1 ), h(x2 ), . . . ,
h(xk ) a des chances égales d’être l’une quelconque des mk séquences de longueur k
d’éléments pris dans {0, 1, . . . , m − 1}.
a. Montrer que, si H est 2-universelle, alors elle est universelle.
b. Soit U l’ensemble des n-uplets de valeurs prises dans Zp , et soit B = Zp , où p
est premier. Pour tout n-uplet a = a0 , a1 , . . . , an−1 de valeurs de Zp et pour
tout b ∈ Zp , on définit la fonction de hachage ha,b : U → B qui, à un n-uplet
x = x0 , x1 , . . . , xn−1 de U, associe
ha,b (x) =
n−1
aj xj + b
mod p
j=0
et soit H = {ha,b }. Montrer que H est 2-universelle.
c. Alice et Bob se sont secrètement entendus pour choisir une fonction de hachage
ha,b dans une famille 2-universelle H de fonctions de hachage. Pus tard, Alice
envoie un message m à Bob via Internet, avec m ∈ U. Elle authentifie ce message
pour Bob en envoyant également une balise d’authentification t = ha,b (m), et Bob
vérifie que la paire (m, t) qu’il reçoit satisfait à t = ha,b (m). Supposez qu’un espion
Notes
245
intercepte (m, t) en route et essaie de tromper Bob en remplaçant la paire par une
autre paire (m , t ). Montrer que la probabilité pour que l’espion réussisse à duper
Bob en lui faisant accepter (m , t ) est au plus 1/p, quelle que soit la puissance de
traitement informatique dont dispose l’espion.
NOTES
c Dunod – La photocopie non autorisée est un délit
Knuth [185] et Gonnet [126] sont d’excellentes références pour l’analyse des algorithmes
de hachage. Knuth attribue à H. P. Luhn (1953) l’invention des tables de hachage, ainsi que
la méthode de résolution des collisions par chaînage. À peu près à la même époque, G. M.
Amdahl jeta les bases de l’adressage ouvert.
Carter et Wegman ont introduit la notion de classes universelles de fonctions de hachage
en 1979 [52].
Fredman, Komlós et Szemerédi [96] ont inventé la stratégie du hachage parfait pour ensembles statiques, étudiée à la section 11.5. Une extension de leur méthode aux ensembles
dynamiques, avec gestion des insertions et des suppressions en temps moyen amorti O(1), a
été donnée par Dietzfelbinger et d’autres. [73].
Chapitre 12
c Dunod – La photocopie non autorisée est un délit
Arbres binaires de recherche
Les arbres(1) de recherche sont des structures de données pouvant supporter nombre
d’opérations d’ensemble dynamique, notamment R ECHERCHER, M INIMUM, M AXI MUM , P RÉDÉCESSEUR , S UCCESSEUR , I NSÉRER et S UPPRIMER . Un arbre de recherche peut donc servir aussi bien de dictionnaire que de file de priorités.
Les opérations basiques sur un arbre binaire de recherche dépensent un temps proportionnel à la hauteur de l’arbre. Pour un arbre binaire complet à n nœuds, ces opérations s’exécutent en Q(lg n) dans le cas le plus défavorable. En revanche, quand
l’arbre se réduit à une chaîne linéaire de n nœuds, les mêmes opérations requièrent un
temps Q(n) dans le cas le plus défavorable. Nous verrons à la section 12.4 que la hauteur attendue d’un arbre binaire de recherche construit aléatoirement est O(lg n), ce
qui fait que les opérations de base d’ensemble dynamique sur un tel arbre requièrent
un temps Q(lg n) en moyenne.
En pratique, on ne peut pas toujours assurer que les arbres binaires de recherche
sont construits aléatoirement, mais certaines variantes de ces arbres permettent de
garantir de bonnes performances dans le cas le plus défavorable pour les opérations
fondamentales. Une de ces variantes, les arbres rouge-noir dont la hauteur est O(lg n),
est présentée au chapitre 13. Le chapitre 18 introduit les B-arbres, qui sont particulièrement efficaces pour gérer des bases de données sur des espaces de stockage
secondaire (disques) à accès aléatoire.
Après une présentation des propriétés fondamentales des arbres binaires de recherche, les sections suivantes expliquent comment parcourir un tel arbre pour afficher les valeurs en ordre trié, comment y rechercher une valeur particulière, comment
(1) Dans cette section, ainsi qu’aux chapitres 13 et 14, nous emploierons le terme « arbre » par abus de
langage, en lieu et place « d’arborescence ». (Voir annexe B .)
12 • Arbres binaires de recherche
248
trouver l’élément minimal ou maximal, comment trouver le prédécesseur ou le successeur d’un élément et comment insérer ou supprimer un élément. Les propriétés
mathématiques fondamentales des arbres sont présentées à l’annexe B.
12.1 QU’EST-CE QU’UN ARBRE BINAIRE DE RECHERCHE ?
Comme son nom l’indique, un arbre binaire de recherche est organisé comme un
arbre binaire, ainsi qu’on peut le voir à la figure 12.1. On peut le représenter par
une structure de données chaînée dans laquelle chaque nœud est un objet. En plus du
champ clé et des données satellites, chaque nœud contient les champs gauche, droite
et p qui pointent sur les nœuds correspondant respectivement à son enfant de gauche,
à son enfant de droite et à son parent. Si le parent, ou l’un des enfant, est absent,
le champ correspondant contient la valeur NIL. Le nœud racine est le seul nœud de
l’arbre dont le champ parent vaut NIL.
5
3
2
2
3
7
5
7
8
5
8
5
(a)
(b)
Figure 12.1 Arbres binaires de recherche. Pour un nœud x, les clés du sous-arbre de gauche
de x valent au plus clé[x] et celles du sous-arbre de droite valent au moins clé[x]. Des arbres
binaires de recherche différents peuvent représenter le même ensemble de valeurs. Le temps
d’exécution, dans le cas le plus défavorable, pour la plupart des opérations d’arbre de recherche
est proportionnel à la hauteur de l’arbre. (a) Un arbre binaire de recherche de 6 nœuds et de
hauteur 2. (b) Un arbre binaire de recherche moins efficace de hauteur 4, contenant les mêmes
clés.
Les clés d’un arbre binaire de recherche sont toujours stockées de manière à satisfaire à la propriété d’arbre binaire de recherche :
Soit x un nœud d’un arbre binaire de recherche. Si y est un nœud du sous-arbre de
gauche de x, alors clé[y] clé[x]. Si y est un nœud du sous-arbre de droite de x, alors
clé[x] clé[y].
Ainsi, dans la figure 12.1(a), la clé de la racine est 5, les clés 2, 3 et 5 de son sousarbre de gauche sont inférieures ou égales à 5 et les clés 7 et 8 de son sous-arbre de
droite sont supérieures ou égales à 5. La propriété reste valable pour chaque nœud de
l’arbre. Par exemple, la clé 3 de la figure 12.1(a) n’est pas inférieure à la clé 2 de son
sous-arbre de gauche, ni supérieure à la clé 5 de son sous-arbre de droite.
12.1
Qu’est-ce qu’un arbre binaire de recherche ?
249
La propriété d’arbre binaire de recherche permet d’afficher toutes les clés de
l’arbre dans l’ordre trié à l’aide d’un algorithme récursif simple, appelé parcours
infixe : la clé de la racine d’un sous-arbre est imprimée entre les clés du sous-arbre de
gauche et les clés du sous-arbre de droite. (De même, un parcours préfixe imprimera
la racine avant les valeurs de chacun de ses sous-arbres et un parcours postfixe
imprimera la racine après les valeurs de ses sous-arbres.) Pour imprimer tous les
éléments d’un arbre binaire de recherche T à l’aide de la procédure suivante, on
appelle PARCOURS -I NFIXE(racine[T]).
PARCOURS -I NFIXE(x)
1 si x fi NIL
2
alors PARCOURS -I NFIXE(gauche[x])
3
afficher clé[x]
4
PARCOURS -I NFIXE(droite[x])
Par exemple, le parcours infixe imprimera les clés de chacun des deux arbres de la figure 12.1 dans l’ordre 2, 3, 5, 5, 7, 8. La validité de l’algorithme se déduit directement
par récurrence de la propriété d’arbre binaire de recherche. Le parcours d’un arbre
binaire de recherche à n nœuds prend un temps Q(n) puisque, après le premier appel,
la procédure est appelée récursivement exactement deux fois pour chaque nœud de
l’arbre (une fois pour son enfant de gauche et une fois pour son enfant de droite). Le
théorème suivant donne une démonstration plus formelle du fait qu’il faut un temps
linéaire pour faire un parcours infixe de l’arbre.
Si x est la racine d’un sous-arbre à n nœuds, alors l’appel
PARCOURS -I NFIXE(x) prend un temps Q(n).
Théorème 12.1
c Dunod – La photocopie non autorisée est un délit
Démonstration : Soit T(n) le temps que met PARCOURS -I NFIXE quand on l’appelle
pour la racine d’un sous-arbre à n nœuds. PARCOURS -I NFIXE prend une petite quantité de temps constante pour un sous-arbre vide (pour faire le test x fi NIL), et donc
T(0) = c où c est une certaine constante positive.
Pour n > 0, supposons que PARCOURS -I NFIXE soit appelée sur un nœud x dont le
sous-arbre de gauche a k nœuds et dont le sous-arbre de droite a n − k − 1 nœuds. Le
temps d’exécution de PARCOURS -I NFIXE (x) est T(n) = T(k) + T(n − k − 1) + d, d étant
une constante positive qui reflète le temps d’exécution de PARCOURS -I NFIXE quand
on ne tient pas compte du temps consommé dans les appels récursifs.
On va utiliser la méthode de substitution pour montrer que T(n) = Q(n) en prouvant
que T(n) = (c + d)n + c. Pour n = 0, on a (c + d)·0 + c = c = T(0). Pour n > 0, on a
T(n) =
=
=
=
T(k) + T(n − k − 1) + d
((c + d)k + c) + ((c + d)(n − k − 1) + c) + d
(c + d)n + c − (c + d) + c + d
(c + d)n + c ,
ce qui termine la démonstration.
❑
250
12 • Arbres binaires de recherche
Exercices
12.1.1 Dessiner des arbres binaires de recherche de hauteur 2, 3, 4, 5 et 6 pour le même
ensemble de clés {1, 4, 5, 10, 16, 17, 21}.
12.1.2 Quelle est la différence entre la propriété d’arbre binaire de recherche et la propriété
de tas min (voir page 123) ? Est-ce que la propriété de tas min permet d’afficher en ordre trié
et en temps O(n) les clés d’un arbre de n nœuds ? Justifier la réponse.
12.1.3 Donner un algorithme non récursif qui effectue un parcours infixe. (conseil : Il existe
une solution simple qui fait appel à une pile comme structure de données auxiliaire et une
solution plus compliquée, mais plus élégante, qui n’utilise aucune pile mais qui suppose
qu’on peut tester l’égalité de deux pointeurs.)
12.1.4 Donner des algorithmes récursifs qui effectuent les parcours préfixe et postfixe en un
temps Q(n) sur un arbre à n nœuds.
12.1.5 Montrer que, puisque le tri de n éléments prend un temps V(n lg n) dans le cas le plus
défavorable dans le modèle de comparaison, tout algorithme basé sur ce modèle qui construit
un arbre binaire de recherche à partir d’une liste arbitraire de n éléments s’effectue au pire en
V(n lg n).
12.2 REQUÊTE DANS UN ARBRE BINAIRE DE RECHERCHE
Une opération très courante sur un arbre binaire de recherche est la recherche d’une
clé stockée dans l’arbre. En dehors de l’opération R ECHERCHER, les arbres binaires
de recherche peuvent supporter des requêtes comme M INIMUM, M AXIMUM, S UC CESSEUR et P RÉDÉCESSEUR . Dans cette section, on examinera ces opérations et on
montrera que chacune peut s’exécuter dans un temps O(h) sur un arbre binaire de
recherche de hauteur h.
a) Recherche
On utilise la procédure suivante pour rechercher un nœud ayant une clé donnée dans
un arbre binaire de recherche. Étant donné un pointeur sur la racine de l’arbre et une
clé k, A RBRE -R ECHERCHER retourne un pointeur sur un nœud de clé k s’il en existe
un ; sinon, elle retourne NIL.
A RBRE -R ECHERCHER(x, k)
1 si x = NIL ou k = clé[x]
2
alors retourner x
3 si k < clé[x]
4
alors retourner A RBRE -R ECHERCHER(gauche[x], k)
5
sinon retourner A RBRE -R ECHERCHER(droite[x], k)
12.2
Requête dans un arbre binaire de recherche
251
La procédure commence sa recherche à la racine et suit un chemin descendant,
comme le montre la figure 12.2. Pour chaque nœud x qu’elle rencontre, elle compare la clé k avec clé[x]. Si les deux clés sont égales, la recherche est terminée. Si
k est plus petite que clé[x], la recherche continue dans le sous-arbre de gauche de
x puisque la propriété d’arbre binaire de recherche implique que k ne peut pas se
trouver dans le sous-arbre de droite. Symétriquement, si k est plus grande que clé[k],
la recherche continue dans le sous-arbre de droite. Les nœuds rencontrés pendant la
récursivité forment un chemin descendant issu de la racine de l’arbre, et le temps
d’exécution de A RBRE -R ECHERCHER est donc O(h) si h est la hauteur de l’arbre.
15
6
7
3
2
18
4
17
20
13
9
Figure 12.2 Interrogations d’un arbre binaire de recherche. Pour rechercher la clé 13 dans
l’arbre, on suit le chemin 15 → 6 → 7 → 13 en partant de la racine. La clé minimale de l’arbre
est 2, qu’on peut atteindre en suivant les pointeurs gauche à partir de la racine. La clé maximale 20 est trouvée en suivant les pointeurs droite à partir de la racine. Le successeur du nœud
de clé 15 est le nœud de clé 17, puisque c’est la clé minimale du sous-arbre de droite de 15. Le
nœud de clé 13 ne possédant pas de sous-arbre de droite, son successeur est le premier de ses
ancêtres (en remontant) dont l’enfant de gauche est aussi un ancêtre. Ici, c’est donc le nœud de
clé 15.
c Dunod – La photocopie non autorisée est un délit
La même procédure peut être réécrite de façon itérative, en « déroulant » la récursivité pour en faire une boucle tant que. Sur la plupart des ordinateurs, cette version
est plus efficace.
A RBRE -R ECHERCHER -I TÉRATIF(x, k)
1 tant que x fi NIL et k fi clé[x]
2
faire si k < clé[x]
3
alors x ← gauche[x]
4
sinon x ← droite[x]
5 retourner x
b) Minimum et maximum
On peut toujours trouver un élément d’un arbre binaire de recherche dont la clé est
un minimum en suivant les pointeurs gauche à partir de la racine jusqu’à ce qu’on
252
12 • Arbres binaires de recherche
rencontre NIL, comme le montre la figure 12.2. La procédure suivante retourne un
pointeur sur l’élément minimal du sous-arbre enraciné au nœud x.
A RBRE -M INIMUM(x)
1 tant que gauche[x] fi NIL
2
faire x ← gauche[x]
3 retourner x
La propriété d’arbre binaire de recherche garantit la validité de A RBRE -M INIMUM.
Si un nœud x ne possède pas de sous-arbre de gauche, alors comme chaque clé du
sous-arbre de droite de x est supérieure ou égale à clé[x], la clé minimale dans le
sous-arbre enraciné en x est clé[x]. Si le nœud x possède un sous-arbre de gauche,
alors comme aucune clé du sous-arbre de droite n’est plus petite que clé[x] et que
chaque clé du sous-arbre gauche est inférieure ou égale à clé[x], la clé minimale du
sous-arbre enraciné en x se trouve dans le sous-arbre enraciné en gauche[x].
Le pseudo code de A RBRE -M AXIMUM est symétrique.
A RBRE -M AXIMUM(x)
1 tant que droite[x] fi NIL
2
faire x ← droite[x]
3 retourner x
Ces deux procédures s’exécutent en O(h) pour un arbre de hauteur h puisque, comme
dans A RBRE -R ECHERCHER, elles suivent des chemins descendants qui partent de la
racine.
c) Successeur et prédécesseur
Étant donné un nœud d’un arbre binaire de recherche, il est parfois utile de pouvoir
trouver son successeur dans l’ordre déterminé par un parcours infixe de l’arbre. Si
toutes les clés sont distinctes, le successeur d’un nœud x est le nœud possédant la plus
petite clé supérieure à clé[x]. La structure d’un arbre binaire de recherche permet de
déterminer le successeur d’un nœud sans même effectuer de comparaison entre les
clés. La procédure suivante retourne le successeur d’un nœud x dans un arbre binaire
de recherche, s’il existe et NIL si x possède la plus grande clé de l’arbre.
A RBRE -S UCCESSEUR(x)
1 si droite[x] fi NIL
2
alors retourner A RBRE -M INIMUM(droite[x])
3 y ← p[x]
4 tant que y fi NIL et x = droite[y]
5
faire x ← y
6
y ← p[y]
7 retourner y
12.2
Requête dans un arbre binaire de recherche
253
Le code de A RBRE -S UCCESSEUR est séparé en deux cas. Si le sous-arbre de droite
du nœud x n’est pas vide, alors le successeur de x est tout simplement le nœud le
plus à gauche dans le sous-arbre de droite, qui est trouvé en ligne 2 en appelant
A RBRE -M INIMUM(droite[x]). Par exemple, le successeur du nœud de clé 15 dans la
figure 12.2 est le nœud de clé 17.
En revanche, ainsi que l’exercice 12.2.6 vous demandera de le montrer, si le sousarbre de droite du nœud x est vide et que x a un successeur y, alors y est le premier
ancêtre de x dont l’enfant de gauche est aussi un ancêtre de x. Sur la figure 12.2, le
successeur du nœud de clé 13 est le nœud de clé 15. Pour trouver y, on remonte tout
simplement l’arbre à partir de x jusqu’à trouver un nœud qui soit l’enfant de gauche de
son parent ; cette remontée est effectuée par les lignes 3–7 de A RBRE -S UCCESSEUR.
Le temps d’exécution de A RBRE -S UCCESSEUR sur un arbre de hauteur h est O(h) ;
en effet, on suit soit un chemin vers le haut de l’arbre, soit un chemin vers le bas. La
procédure A RBRE -P RÉDÉCESSEUR, qui est symétrique de A RBRE -S UCCESSEUR,
s’exécute également dans un temps O(h).
Même si les clés ne sont pas distinctes, on définit le successeur et le prédécesseur
d’un nœud x comme étant le nœud retourné par des appels à A RBRE -S UCCESSEUR(x)
et A RBRE -P RÉDÉCESSEUR(x) respectivement.
En résumé, nous venons de démontrer le théorème suivant :
Théorème 12.2 Les opérations d’ensemble dynamique R ECHERCHER, M INIMUM ,
M AXIMUM, S UCCESSEUR et P RÉDÉCESSEUR peuvent se faire en temps O(h) sur un
arbre binaire de recherche de hauteur h.
Exercices
c Dunod – La photocopie non autorisée est un délit
12.2.1 On suppose que des entiers compris entre 1 et 1000 sont disposés dans un arbre binaire de recherche et que l’on souhaite trouver le nombre 363. Parmi les séquences suivantes,
lesquelles ne pourraient pas être la suite des nœuds parcourus ?
a.
b.
c.
d.
e.
2, 252, 401, 398, 330, 344, 397, 363.
924, 220, 911, 244, 898, 258, 362, 363.
925, 202, 911, 240, 912, 245, 363.
2, 399, 387, 219, 266, 382, 381, 278, 363.
935, 278, 347, 621, 299, 392, 358, 363.
12.2.2 Écrire des versions récursives des procédures A RBRE -M INIMUM et A RBRE M AXIMUM.
12.2.3 Écrire la procédure A RBRE -P RÉDÉCESSEUR.
12.2.4 Le professeur Szmoldu pense avoir découvert une remarquable propriété des arbres
binaires de recherche. Supposez que la recherche d’une clé k dans un arbre binaire de
recherche se termine sur une feuille. On considère trois ensembles : A, les clés situées à
254
12 • Arbres binaires de recherche
gauche du chemin de recherche ; B, celles situées sur le chemin de recherche ; et C, les clés
situées à droite du chemin de recherche. Le professeur Szmoldu affirme que, étant donnés
trois a ∈ A, b ∈ B et c ∈ C quelconques, ils doivent satisfaire à a b c. Donner un
contre-exemple qui soit le plus petit possible.
12.2.5 Montrer que, si un nœud d’un arbre binaire de recherche a deux enfants, alors son
successeur n’a pas d’enfant de gauche et son prédécesseur n’a pas d’enfant de droite.
12.2.6 Soit un arbre binaire de recherche T dont les clés sont distinctes. Montrer que, si
le sous-arbre de droite d’un nœud x dans T est vide et que x a un successeur y, alors y est
l’ancêtre le plus bas de x dont l’enfant de gauche est aussi un ancêtre de x. (Ne pas oublier
que chaque nœud est son propre ancêtre.)
12.2.7 On peut implémenter un parcours infixe d’un arbre à n nœuds en trouvant l’élément
minimal de l’arbre à l’aide de A RBRE -M INIMUM, puis en appelant n − 1 fois A RBRE S UCCESSEUR. Démontrer que cet algorithme s’exécute dans un temps Q(n).
12.2.8 Démontrer que, quel que soit le nœud dont on part dans un arbre binaire de recherche
de hauteur h, k appels successifs à A RBRE -S UCCESSEUR dépensent un temps O(k + h).
12.2.9 Soit T un arbre binaire de recherche dont les clés sont distinctes, soit x un nœud
feuille et soit y son parent. Montrer que clé[y] est soit la plus petite clé de T supérieure à
clé[x], soit la plus grande clé de T inférieure à clé[x].
12.3 INSERTION ET SUPPRESSION
Les opérations d’insertion et de suppression modifient l’ensemble dynamique représenté par un arbre binaire de recherche. La structure de données doit être modifiée
pour refléter ce changement, tout en conservant la propriété d’arbre binaire de recherche. Comme nous le verrons, modifier l’arbre pour y insérer un nouvel élément
est relativement simple, mais la suppression est un peu plus délicate à gérer.
a) Insertion
Pour insérer une nouvelle valeur v dans un arbre binaire de recherche T, on utilise
la procédure A RBRE -I NSÉRER. On lui passe un nœud z pour lequel clé[z] = v,
gauche[z] = NIL et droite[z] = NIL. Elle modifie T et certains champs de z pour
permettre à z d’être inséré à sa position correcte dans l’arbre.
La figure 12.3 montre le fonctionnement de A RBRE -I NSÉRER. Comme pour
A RBRE -R ECHERCHER et A RBRE -R ECHERCHER -I TÉRATIF, A RBRE -I NSÉRER part
de la racine de l’arbre et suit un chemin descendant. Le pointeur x parcourt le
chemin et le parent de x est conservé dans le pointeur y. Après l’initialisation, la
boucle tant que des lignes 3–7 fait descendre ces deux pointeurs le long de l’arbre,
bifurquant à gauche ou à droite en fonction du résultat de la comparaison entre clé[z]
12.3
Insertion et suppression
255
12
5
2
18
9
19
15
13
17
Figure 12.3 Insertion d’un élément de clé 13 dans un arbre binaire de recherche. Les nœuds
sur fond gris clair indiquent le chemin descendant de la racine vers la position où l’élément sera
inséré. La ligne pointillée représente le lien qui est ajouté à l’arbre pour insérer l’élément.
et clé[x], jusqu’à ce que x prenne la valeur NIL. Ce NIL occupe la position où l’on
souhaite placer l’élément d’entrée z. Les lignes 8–13 initialisent les pointeurs qui
provoquent l’insertion de z.
A RBRE -I NSÉRER(T, z)
1 y ← NIL
2 x ← racine[T]
3 tant que x fi NIL
4
faire y ← x
5
si clé[z] < clé[x]
6
alors x ← gauche[x]
7
sinon x ← droite[x]
8 p[z] ← y
9 si y = NIL
10
alors racine[T] ← z
11
sinon si clé[z] < clé[y]
12
alors gauche[y] ← z
13
sinon droite[y] ← z
arbre T était vide
c Dunod – La photocopie non autorisée est un délit
Comme les autres opérations primitives d’arbre binaire de recherche, la procédure
A RBRE -I NSÉRER s’exécute dans un temps O(h) sur un arbre de hauteur h.
b) Suppression
La procédure permettant de supprimer un nœud donné z d’un arbre binaire de recherche prend comme argument un pointeur sur z. La procédure considère les trois
cas montrés à la figure 12.4. Si z n’a pas d’enfant, on modifie son parent p[z] pour
remplacer z par NIL dans le champ enfant. Si le nœud n’a qu’un seul enfant, on « détache » z en créant un nouveau lien entre son enfant et son parent. Enfin, si le nœud
a deux enfants, on détache le successeur de z, y, qui n’a pas d’enfant gauche (voir
exercice 12.2.5) et on remplace la clé et les données satellites de z par la clé et les
données satellite de y.
Le code de A RBRE -S UPPRIMER gère ces trois cas un peu différemment.
12 • Arbres binaires de recherche
256
15
5
15
16
3
5
12
10
20
13 z
18
16
3
12
23
20
10
6
18
23
6
7
7
(a)
15
5
15
16 z
3
12
10
5
20
13
18
20
3
12
23
10
6
18
23
13
6
7
7
(b)
y 6
15
z 5
16
3
12
10
20
13
15
z 5
18
3
6
12
23
10
y 6
15
16
20
13
18
16
3
12
23
7
10
20
13
18
23
7
7
(c)
Figure 12.4 Suppression d’un nœud z d’un arbre binaire de recherche. Pour savoir quel est le
nœud à supprimer effectivement, on regarde le nombre d’enfants de z ; ce nœud est colorié en
gris clair. (a) Si z n’a pas d’enfant, on se contente de le supprimer. (b) Si z n’a qu’un seul enfant,
on détache z. (c) Si z a deux enfant, on détache son successeur y qui possède au plus un enfant
et on remplace alors la clé et les données satellites de z par celles de y.
A RBRE -S UPPRIMER(T, z)
1 si gauche[z] = NIL ou droite[z] = NIL
2
alors y ← z
3
sinon y ← ARBRE - SUCCESSEUR(z)
4 si gauche[y] fi NIL
5
alors x ← gauche[y]
6
sinon x ← droite[y]
7 si x fi NIL
8
alors p[x] ← p[y]
9 si p[y] = NIL
10
alors racine[T] ← x
11
sinon si y = gauche[p[y]]
12
alors gauche[p[y]] ← x
13
sinon droite[p[y]] ← x
14 si y fi z
15
alors clé[z] ← clé[y]
16
copier données satellites de y dans z
17 retourner y
12.3
Insertion et suppression
257
Aux lignes 1–3, l’algorithme détermine un nœud y à détacher. Le nœud y est soit le
nœud d’entrée z (si z a au plus 1 enfant), soit le successeur de z (si z a deux enfant).
Puis, aux lignes 4–6, x est rendu égal à l’enfant non-NIL de y ou à NIL si y n’a pas
d’enfant. Le nœud y est détaché aux lignes 7–13 via modification des pointeurs dans
p[y] et x. Détacher y est assez compliqué, du fait de la nécessité de bien gérer les
conditions aux limites, qui surviennent lorsque x = NIL ou quand y est la racine.
Enfin, aux lignes 14–16, si le successeur de z était le nœud détaché, la clé et les
données satellites de y sont déplacées dans z, écrasant alors la clé et les données
satellites précédentes. Le nœud y est retourné en ligne 17 pour que la procédure
appelante puisse le recycler via la liste libre. La procédure s’exécute en temps O(h)
sur un arbre de hauteur h.
En résumé, nous venons de démontrer le théorème suivant :
Théorème 12.3 On peut exécuter les opérations I NSÉRER et S UPPRIMER d’ensemble
dynamique en temps O(h) sur un arbre binaire de recherche de hauteur h.
Exercices
12.3.1 Donner une version récursive de la procédure A RBRE -I NSÉRER.
12.3.2 On suppose qu’un arbre binaire de recherche est construit par insertion répétée de valeurs distinctes dans l’arbre. Dire pourquoi le nombre de nœuds examinés lors de la recherche
d’une valeur dans l’arbre vaut un de plus que le nombre de nœuds examinés quand la valeur
a été insérée pour la première fois dans l’arbre.
c Dunod – La photocopie non autorisée est un délit
12.3.3 On peut trier un ensemble donné de n nombres en commençant par construire un
arbre binaire de recherche contenant ces nombres (en répétant A RBRE -I NSÉRER pour insérer
les nombres un à un), puis en imprimant les nombres via un parcours infixe de l’arbre. Quels
sont les temps d’exécution de cet algorithme de tri, dans le pire et dans le meilleur des cas ?
12.3.4 On suppose qu’une autre structure de données contient un pointeur sur un nœud y
d’un arbre binaire de recherche et que z, le prédécesseur de y est supprimé de l’arbre par
la procédure A RBRE -S UPPRIMER. Quel problème peut-il se produire ? Comment peut-on
réécrire A RBRE -S UPPRIMER pour résoudre ce problème ?
12.3.5 L’opération de suppression est-elle « commutative » au sens où la suppression de x
puis de y dans un arbre binaire de recherche produit le même arbre que la suppression de y
puis de x ? Si oui, dire pourquoi ; sinon, donner un contre-exemple.
12.3.6 Lorsque le nœud z dans A RBRE -S UPPRIMER a deux enfants, on pourrait détacher
son prédécesseur plutôt que son successeur. Certains ont dit qu’une stratégie équilibrée, qui
donnerait des priorités égales au prédécesseur et au successeur, donnerait des performances
empiriques meilleures. Comment pourrait-on modifier A RBRE -S UPPRIMER pour implémenter cette stratégie équilibrée ?
12 • Arbres binaires de recherche
258
12.4 ARBRES BINAIRES DE RECHERCHE CONSTRUITS
ALÉATOIREMENT
Nous avons montré que toutes les opérations basiques d’arbre binaire de recherche
se font en temps O(h), h étant la hauteur de l’arbre. La hauteur d’un arbre binaire
de recherche varie, cependant, au fur et à mesure que l’on ajoute ou supprime des
éléments. Si, par exemple, on insère les éléments dans l’ordre strictement croissant,
alors l’arbre sera une chaîne de hauteur n−1. D’un autre côté, l’exercice B.5.4 montre
que h lg n . Comme pour le tri rapide, on peut montrer que le comportement du
cas moyen est beaucoup plus proche du cas optimal que du cas le plus défavorable.
Malheureusement, on sait peu de choses sur la hauteur moyenne d’un arbre binaire
quand il est créé par des insertions et par des suppressions. Quand l’arbre n’est créé
que par des insertions, l’analyse devient plus accessible. Définissons donc un arbre
binaire de recherche construit aléatoirement à n clés comme étant un arbre qui
résulte de l’ajout de clés dans un ordre aléatoire à un arbre initialement vide, chacune
des n! permutations des clés d’entrée étant équiprobable. (L’exercice 12.4.3 vous
demandera de montrer que cette notion n’est pas la même chose que de supposer
que chaque arbre binaire de recherche à n clés est équiprobable.) Cette section va
démontrer que la hauteur attendue d’un arbre binaire de recherche à n clés construit
aléatoirement est O(lg n). On supposera que toutes les clés sont distinctes.
Commençons par définir trois variables aléatoires qui facilitent la mesure de la
hauteur d’un arbre binaire de recherche construit aléatoirement. On note Xn la hauteur
d’un arbre binaire de recherche à n clés construit aléatoirement et l’on définit la
hauteur exponentielle Yn = 2Xn . Quand on construit un arbre binaire de recherche à
n clés, on choisit une clé comme clé de la racine et on note Rn la variable aléatoire
qui contient le rang de cette clé dans l’ensemble des n clés. La valeur de Rn a une
chance égale d’être l’un quelconque des éléments de l’ensemble {1, 2, . . . , n}. Si
Rn = i, alors le sous-arbre de gauche de la racine est un arbre binaire de recherche
à i − 1 clés construit aléatoirement et le sous-arbre de droite est un arbre binaire de
recherche à n − i clés construit aléatoirement. Comme la hauteur d’un arbre binaire
est supérieure d’une unité à la plus grande des hauteurs des deux sous-arbres de la
racine, la hauteur exponentielle d’un arbre binaire vaut deux fois le maximum des
hauteurs exponentielles des deux sous-arbres de la racine. Si l’on sait que Rn = i, on
a donc
Yn = 2· max(Yi−1 , Yn−i ) .
Comme cas de base, on a Y1 = 1 car la hauteur exponentielle d’un arbre à 1 nœud est
20 = 1 et, par commodité, on définit Y0 = 0.
On définit ensuite des variables indicatrices Zn,1 , Zn,2 , . . . , Zn,n , où
Zn,i = I {Rn = i} .
12.4
Arbres binaires de recherche construits aléatoirement
259
Comme Rn a une chance égale d’être l’un quelconque des éléments de {1, 2, . . . , n},
on a Pr {Rn = i} = 1/n pour i = 1, 2, . . . , n ; d’où, d’après le lemme 5.1,
E [Zn,i ] = 1/n ,
(12.1)
pour i = 1, 2, . . . , n. Comme il y a exactement une seule valeur de Zn,i qui est 1 et que
toutes les autres sont 0, on a également
Yn =
n
Zn,i (2· max(Yi−1 , Yn−i )) .
i=1
Nous allons montrer que E [Yn ] est polynomial en n, ce qui impliquera au final que
E [Xn ] = O(lg n).
La variable indicatrice Zn,i = I {Rn = i} est indépendante des valeurs de Yi−1
et Yn−i . En ayant choisi Rn = i, le sous-arbre de gauche, dont la hauteur exponentielle est Yi−1 , est construit aléatoirement sur les i − 1 clés dont les rangs sont inférieurs à i. Ce sous-arbre ressemble en tout point à n’importe quel autre arbre binaire
de recherche construit aléatoirement sur i − 1 clés. À part le nombre de clés qu’elle
contient, la structure de ce sous-arbre n’est nullement affectée par le choix de Rn = i ;
et donc, les variables aléatoires Yi−1 et Zn,i sont indépendantes. De même, le sousarbre de droite, dont la hauteur exponentielle est Yn−i , est construit aléatoirement sur
les n − i clés dont les rangs sont supérieurs à i. Sa structure est indépendante de la
valeur de Rn , et donc les variables aléatoires Yn−i et Zn,i sont indépendantes. D’où
n
Zn,i (2· max(Yi−1 , Yn−i ))
E [Yn ] = E
i=1
=
n
E [Zn,i (2· max(Yi−1 , Yn−i ))]
(d’après la linéarité de l’espérance)
c Dunod – La photocopie non autorisée est un délit
i=1
=
n
E [Zn,i ] E [2· max(Yi−1 , Yn−i )] (d’après l’indépendance)
i=1
=
n
1
i=1
n
·E [2· max(Yi−1 , Yn−i )]
(d’après l’équation (12.1))
2
E [max(Yi−1 , Yn−i )]
n
(d’après l’équation C.21)
2
(E [Yi−1 ] + E [Yn−i ])
n
(d’après l’exercice C.3.4) .
n
=
i=1
n
i=1
12 • Arbres binaires de recherche
260
Chaque terme E [Y0 ] , E [Y1 ] , . . . , E [Yn−1 ] apparaît deux fois dans la dernière sommation, une fois en tant que E [Yi−1 ] et une fois en tant que E [Yn−i ], d’où la récurrence
n−1
4
(12.2)
E [Yi ] .
E [Yn ] n
i=0
En employant la méthode par substitution, nous allons montrer que, pour tous les
entiers positifs n, la récurrence (12.2) admet la solution
E [Yn ] 1 n+3
.
4
3
Ce faisant, nous utiliserons l’identité
n−1
i+3
3
n+3
.
4
=
i=0
(12.3)
(L’exercice 12.4.1 vous demandera de prouver cette identité.)
Pour le cas de base, on vérifie que la borne
1 = Y1 = E [Y1 ] 1 1+3
4
3
=1
est vérifiée. Pour la substitution, on a
n−1
4
E [Yi ]
E [Yn ] n
i=0
41 i+3
n
4 3
n−1
(d’après l’hypothèse de récurrence)
i=0
1 i+3
n
3
n−1
=
i=0
=
1 n+3
4
n
=
1 (n + 3)!
·
n 4! (n − 1)!
=
1 (n + 3)!
·
4 3! n!
=
1 n+3
.
4
3
(d’après l’équation (12.3))
12.4
Arbres binaires de recherche construits aléatoirement
261
On a majoré E [Yn ], mais l’objectif final est de borner E [Xn ]. Ainsi que l’exercice 12.4.4 vous demandera de le montrer, la fonction f (x) = 2x est convexe (voir
page 1072). On peut donc appliquer l’inégalité de Jensen (C.25) qui dit que
2E[Xn ] E [2Xn ] = E [Yn ] ,
pour en déduire que
2E[Xn ] =
=
1 n+3
4
3
1 (n + 3)(n + 2)(n + 1)
·
4
6
n3 + 6n2 + 11n + 6
.
24
En prenant les logarithmes des deux membres, on obtient E [Xn ] = O(lg n). Ainsi,
nous avons démontré le résultat suivant :
Théorème 12.4 La hauteur moyenne d’un arbre binaire de recherche construit aléatoirement à partir de n clés est O(lg n).
Exercices
12.4.1 Prouver l’équation (12.3).
c Dunod – La photocopie non autorisée est un délit
12.4.2 Décrire un arbre binaire de recherche à n nœuds tel que la profondeur moyenne d’un
nœud dans l’arbre soit Q(lg n), mais que la hauteur de l’arbre soit v(lg n). Donner un majorant asymptotique pour la hauteur d’un arbre binaire de recherche à n nœuds dans lequel la
profondeur moyenne d’un nœud est Q(lg n).
12.4.3 Montrer que la notion d’arbre binaire de recherche choisi aléatoirement sur n clés,
où chaque arbre binaire de recherche de n clés a une chance égale d’être choisi, diffère de la
notion d’arbre binaire de recherche construit aléatoirement, vue dans cette section. (conseil :
Énumérer les possibilités quand n = 3.)
12.4.4 Montrer que la fonction f (x) = 2x est convexe.
12.4.5 On considère l’action de T RI -R APIDE -R ANDOMISE sur une séquence d’entrée de
n nombres. Démontrer que, pour toute constante k > 0, toutes les n! permutations de l’entrée,
sauf O(1/nk ) d’entre elles, engendrent un temps d’exécution O(n lg n).
12 • Arbres binaires de recherche
262
PROBLÈMES
12.1. Arbres binaires de recherche contenant des clés égales
L’égalité entre clés pose un problème pour l’implémentation des arbres binaires de
recherche.
a. Quelles sont les performances asymptotiques de A RBRE -I NSÉRER quand on l’utilise pour insérer n éléments ayant des clés identiques dans un arbre binaire de
recherche initialement vide ?
On se propose d’améliorer A RBRE -I NSÉRER en lui faisant tester avant la ligne 5 si
clé[z] = clé[x] et en lui faisant tester avant la ligne 11 si clé[z] = clé[y]. En cas d’égalité, on met en œuvre l’une des stratégies suivantes. Pour chaque stratégie, trouver les
performances asymptotiques de l’insertion de n éléments ayant des clés identiques
dans un arbre binaire de recherche initialement vide. (Les stratégies sont décrites
pour la ligne 5, dans laquelle on compare les clés de z et x. Remplacer x par y pour
adapter ces stratégies à la ligne 11.)
b. Gérer un indicateur booléen b[x] dans le nœud x. Initialiser x à gauche[x] ou à
droite[x] selon la valeur de b[x], qui alterne entre FAUX et VRAI chaque fois que
le nœud est visité pendant l’insertion d’un nœud ayant la même clé que x.
c. Gérer une liste de nœuds ayant des clés égales à x et insérer z dans la liste.
d. Initialiser x aléatoirement à gauche[x] ou à droite[x]. (Donner les performances
dans le cas le plus défavorable et en déduire, de manière informelle, les performances dans le cas moyen.)
12.2. Arbres à base
Étant données deux chaînes a = a0 a1 . . . ap et b = b0 b1 . . . bq , où chaque ai et chaque
bj appartiennent à un ensemble ordonné de caractères, on dit que la chaîne a est
lexicographiquement inférieure à la chaîne b si
1) il existe un entier j, avec 0 j min(p, q), tel que ai = bi pour tout
i = 0, 1, . . . , j − 1 et aj < bj , ou
2) p < q et ai = bi pour tout i = 0, 1, . . . , p.
Par exemple, si a et b sont des chaînes de bits, alors 10100 < 10110 d’après la règle 1
(en prenant j = 3) et 10100 < 101000 d’après la règle 2. Cela équivaut à l’ordre en
usage dans les dictionnaires.
La structure de données d’arbre à base montrée à la figure 12.5 contient les chaînes
de bits 1011, 10, 011, 100 et 0. Lors d’une recherche d’une clé a = a0 a1 . . . ap , on se
dirige vers la gauche au nœud de profondeur i si ai = 0 et vers la droite si ai = 1. Soit
S un ensemble de chaînes binaires distinctes dont les longueurs ont pour cumul n.
Montrer comment utiliser un arbre à base pour trier S lexicographiquement en temps
Problèmes
263
Q(n). Pour l’exemple de la figure 12.5, la sortie du tri serait la séquence 0, 011, 10,
100, 1011.
0
1
0
1
0
10
1
011
0
100
1
1
1011
Figure 12.5 Un arbre à base contenant les chaînes de bits 1011, 10, 011, 100 et 0. La clé de
chaque nœud peut être déterminée en empruntant le chemin reliant la racine à ce nœud. Il est
donc inutile de conserver les clés dans les nœuds ; les clés sont montrées ici uniquement pour
des raisons de clarté. Les nœuds sont en gris foncé si les clés correspondantes ne sont pas dans
l’arbre ; ces nœuds servent uniquement à créer des liens entre d’autres nœuds.
12.3. Profondeur moyenne d’un nœud dans un arbre binaire de
recherche construit aléatoirement
Dans ce problème, on démontre que la profondeur moyenne d’un nœud dans un
arbre binaire de recherche construit aléatoirement à n nœuds est O(lg n). Bien que
ce résultat soit moins fort que celui du théorème 12.4, la technique que nous allons
utiliser révèle une surprenante ressemblance entre la construction d’un arbre binaire
de recherche et l’exécution de TRI - RAPIDE - RANDOMISÉ vu à la section 7.3.
On définit la longueur de chemin totale P(T) d’un arbre binaire T comme étant la
somme, prise sur tous les nœuds x de T, de la profondeur du nœud x notée d(x, T).
c Dunod – La photocopie non autorisée est un délit
a. Montrer que la profondeur moyenne d’un nœud dans T est
1
1
d(x, T) = P(T) .
n
n
x∈T
Ainsi, on veut montrer que la valeur attendue de P(T) est O(n lg n).
b. Soient TG et TD les sous-arbres de gauche et de droite de l’arbre T. Montrer que,
si T possède n nœuds, alors
P(T) = P(TG ) + P(TD ) + n − 1 .
c. Soit P(n) la longueur de chemin totale moyenne d’un arbre binaire de recherche à
n nœuds construit aléatoirement. Montrer que
1
(P(i) + P(n − i − 1) + n − 1) .
n
n−1
P(n) =
i=0
12 • Arbres binaires de recherche
264
d. Montrer que P(n) peut être réécrit sous la forme
2
P(n) =
P(k) + Q(n) .
n
n−1
k=1
e. En s’inspirant de l’analyse alternative de la version randomisée du tri rapide (voir
problème 7.2, conclure que P(n) = O(n lg n).
A chaque appel récursif du tri rapide, on choisit au hasard un élément pivot pour
partitionner l’ensemble des éléments en cours de tri. Chaque nœud d’un arbre binaire
de recherche crée une partition à partir de l’ensemble des éléments qui se trouvent
dans le sous-arbre enraciné à ce nœud.
f. Décrire une implémentation du tri rapide dans laquelle les comparaisons servant
à trier un ensemble d’éléments sont exactement les mêmes que celles qui servent
à insérer les éléments dans un arbre binaire de recherche. (L’ordre dans lequel
sont effectuées les comparaisons peut changer, mais les comparaisons elles-mêmes
doivent être identiques).
12.4. Nombre d’arbres binaires différents
Soit bn le nombre d’arbres binaires différents à n nœuds. Dans ce problème, on devra
trouver une formule donnant bn ainsi qu’une estimation asymptotique.
a. Montrer que b0 = 1 et que, pour n 1,
bn =
n−1
bk bn−1−k .
k=0
b. Soit B(x) la fonction génératrice
B(x) =
∞
bn x n
n=0
(voir problème 4.5 pour la définition d’une fonction génératrice). Montrer que
B(x) = xB(x)2 + 1, et donc qu’une façon d’exprimer B(x) sous forme fermée est
√
"
1 !
B(x) =
1 − 1 − 4x .
2x
Le développement de Taylor de f (x) autour du point x = a est donné par
f (x) =
∞ (k)
f (a)
k!
k=0
où
f (k) (x)
(x − a)k ,
est la dérivée kième de f en x.
c. Montrer que
bn =
1
2n
n+1 n
Notes
265
√
(nième nombre de Catalan) en utilisant le développement de Taylor de 1 − 4x
autour de x = 0. (A la place du développement de Taylor, on peut utiliser la
généralisation du développement binomial (C.4) aux exposants! n" non entiers :
pour un nombre réel n et un entier k quelconques, on interprète nk comme étant
n(n − 1) · · · (n − k + 1)/k! si k 0 et 0 sinon.)
d. Montrer que
4n
bn = √ 3/2 (1 + O(1/n)) .
pn
NOTES
Knuth [185] contient une bonne étude des arbres binaires de recherche simples et de nombreuses variantes. Les arbres binaires de recherche semblent avoir été découverts indépendamment par plusieurs personnes vers la fin des années 1950. Knuth [185] traite aussi des
arbres à base.
c Dunod – La photocopie non autorisée est un délit
La section 15.5 montrera comment construire un arbre binaire de recherche optimal quand
les fréquences de recherche sont connues avant la construction de l’arbre. En d’autres termes :
connaissant les fréquences de recherche pour chaque clé et les fréquences de recherche pour
les valeurs qui tombent entre des clés de l’arbre, on construit un arbre binaire de recherche
tel qu’un ensemble de recherches qui suit ces fréquences examinera le nombre minimal de
nœuds.
La démonstration donnée à la section 12.4qui borne la hauteur moyenne d’un arbre binaire
de recherche construit aléatoirement est due à Aslam [23]. Martínez et Roura [211] donnent
des algorithmes randomisés pour l’insertion et la suppression dans les arbres binaires de
recherche dans lesquels le résultat de l’une ou l’autre de ces deux opérations est un arbre
binaire de recherche aléatoire. Leur définition d’un arbre binaire de recherche aléatoire diffère
légèrement, cependant, de celle d’un arbre binaire de recherche construit aléatoirement telle
que donnée dans ce chapitre.
Chapitre 13
c Dunod – La photocopie non autorisée est un délit
Arbres rouge-noir
Le chapitre 12 a montré qu’un arbre binaire de recherche de hauteur h permettait
de mettre en œuvre les opérations fondamentales d’ensemble dynamique, telles R E CHERCHER, P RÉDÉCESSEUR , S UCCESSEUR , M INIMUM , M AXIMUM , I NSÉRER et
S UPPRIMER, avec un temps O(h). Les opérations sont donc d’autant plus rapides que
la hauteur de l’arbre est petite ; mais si cette hauteur est grande, les performances
risquent de ne pas être meilleures qu’avec une liste chaînée. Les arbres rouge-noir
sont l’un des nombreux modèles d’arbres de recherche « équilibrés », qui permettent
aux opérations d’ensemble dynamique de s’exécuter en temps O(lg n) dans le cas le
plus défavorable.
13.1 PROPRIÉTÉS DES ARBRES ROUGE-NOIR
Un arbre rouge-noir est un arbre binaire de recherche comportant un bit de stockage
supplémentaire par nœud : sa couleur, qui peut être ROUGE ou NOIR. En contrôlant
la manière dont les nœuds sont coloriés sur n’importe quel chemin allant de la racine
à une feuille, les arbres rouge-noir garantissent qu’aucun de ces chemins n’est plus de
deux fois plus long que n’importe quel autre, ce qui rend l’arbre approximativement
équilibré.
268
13 • Arbres rouge-noir
Chaque nœud de l’arbre contient maintenant les champs couleur, clé, gauche,
droite et p. Si un enfant ou le parent d’un nœud n’existe pas, le champ pointeur
correspondant du nœud contient la valeur NIL. Nous considérerons ces NIL comme
des pointeurs vers des nœuds externes (feuilles) de l’arbre binaire de recherche et les
nœuds normaux, dotés de clés, comme des nœuds internes de l’arbre.
Un arbre binaire de recherche est un arbre rouge-noir s’il satisfait aux propriétés
suivantes :
1) Chaque nœud est soit rouge, soit noir.
2) La racine est noire.
3) Chaque feuille (NIL) est noire.
4) Si un nœud est rouge, alors ses deux enfants sont noirs.
5) Pour chaque nœud, tous les chemins reliant le nœud à des feuilles contiennent le
même nombre de nœuds noirs.
On peut voir un exemple d’arbre rouge-noir à la figure 13.1.
Pour simplifier le traitement des conditions aux limites dans la programmation
des arbres rouge-noir, on utilise une même sentinelle pour représenter NIL (voir
page 201). Pour un arbre rouge-noir T, la sentinelle nil[T] est un objet ayant les
mêmes champs qu’un nœud ordinaire. Son champ couleur vaut NOIR, et ses autres
champs (p, gauche, droite et clé) peuvent prendre des valeurs quelconques. Comme le
montre la figure 13.1(b), tous les pointeurs vers NIL sont remplacés par des pointeurs
vers la sentinelle nil[T].
On utilise la sentinelle pour pouvoir traiter un enfant NIL d’un nœud x comme un
nœud ordinaire dont le parent est x. On pourrait ajouter un nœud sentinelle distinct
pour chaque NIL de l’arbre, pour que le parent de chaque NIL soit bien défini, mais
cette méthode gaspillerait de l’espace. À la place, on emploie une seule sentinelle
nil[T] pour représenter tous les NIL (à savoir les feuilles et le parent de la racine). Les
valeurs des champs p, gauche, droite et clé de la sentinelle sont immatérielles, bien
que nous puissions les configurer à notre convenance dans le cours d’une procédure.
On ne s’intéresse généralement qu’aux nœuds internes d’un arbre rouge-noir, vu
que ce sont eux qui contiennent les valeurs de clé. Dans le reste du chapitre, nous
omettrons les feuilles quand nous dessinerons des arbres rouge-noir, comme c’est le
cas sur la figure 13.1(c).
On appelle hauteur noire le nombre de nœuds noirs d’un chemin partant d’un
nœud x (non compris ce nœud) vers une feuille, et on utilise la notation bh(x). D’après
la propriété 5, la notion de hauteur noire est bien définie, puisque tous les chemins
descendant d’un nœud contiennent le même nombre de nœuds noirs. On définit la
hauteur noire d’un arbre rouge-noir comme étant la hauteur noire de sa racine. Le
lemme suivant montre pourquoi les arbres rouge-noir sont de bons arbres de recherche.
13.1
Propriétés des arbres rouge-noir
269
3
3
2
2
1
1
7
3
NIL
1
NIL
12
1
NIL
21
2
1
NIL
41
17
14
10
16
15
NIL
26
1
NIL
19
NIL
NIL
2
1
1
20
NIL
23
NIL
1
NIL
30
1
28
NIL
NIL
NIL
1
1
38
35
1
NIL
NIL
2
NIL
47
NIL
NIL
39
NIL
NIL
(a)
26
17
41
14
21
10
7
16
12
19
15
30
23
47
28
38
20
35
39
3
nil[T]
(b)
26
17
41
14
21
10
7
c Dunod – La photocopie non autorisée est un délit
3
16
12
15
19
30
23
47
28
20
38
35
39
(c)
Figure 13.1 Un arbre rouge-noir dont les nœuds noirs sont représentés en noir, et les nœuds
rouges en gris. Chaque nœud d’un arbre rouge-noir est rouge ou noir, les enfants d’un nœud
rouge sont noirs, et chaque chemin simple reliant un nœud à une feuille contient le même nombre
de nœuds noirs. (a) Chaque feuille, représentée par (NIL), est noire. Chaque nœud non-NIL est
étiqueté par sa hauteur noire ; les NIL ont une hauteur noire égale à 0. (b) Le même arbre rougenoir, mais où chaque NIL est remplacé par la sentinelle nil[T], qui est toujours noire, et où les
hauteurs noires sont omises. Le parent de la racine est aussi la sentinelle. (c) Le même arbre rougenoir, mais où les feuilles et le parent de la racine ont été entièrement omis. Nous emploierons ce
style de représentation dans le reste du chapitre.
13 • Arbres rouge-noir
270
Lemme 13.1 Un arbre rouge-noir ayant n nœuds internes a une hauteur au plus égale
à 2 lg(n + 1).
Démonstration : Commençons par montrer que le sous-arbre enraciné en un nœud
x quelconque contient au moins 2bh(x) − 1 nœuds internes. Cette affirmation peut se
démontrer par récurrence sur la hauteur de x. Si la hauteur de x est 0, alors x est obligatoirement une feuille (nil[T]) et le sous-arbre enraciné en x contient effectivement au
moins 2bh(x) − 1 = 20 − 1 = 0 nœuds internes. Pour l’étape inductive, soit un nœud interne x de hauteur positive ayant deux enfants. Chaque enfant a une hauteur noire bh(x)
ou bh(x) − 1, selon que sa couleur est rouge ou noire. Comme la hauteur d’un enfant
de x est inférieure à celle de x lui-même, on peut appliquer l’hypothèse de récurrence
pour conclure que chaque enfant a au moins 2bh(x)−1 −1 nœuds internes. Le sous-arbre
de racine x contient donc au moins (2bh(x)−1 − 1) + (2bh(x)−1 − 1) + 1 = 2bh(x) − 1 nœuds
internes, ce qui démontre l’assertion.
Pour compléter la preuve du lemme, appelons h la hauteur de l’arbre. D’après la propriété 4, au moins la moitié des nœuds d’un chemin simple reliant la racine à une
feuille, racine non comprise, doivent être noirs. En conséquence, la hauteur noire de
la racine doit valoir au moins h/2 ; donc,
n 2h/2 − 1 .
En faisant passer le 1 dans le membre gauche et en prenant le logarithme des deux
membres, on obtient lg(n + 1) h/2, soit h 2 lg(n + 1).
❑
Une conséquence immédiate de ce lemme est que les opérations d’ensemble dynamique R ECHERCHER, M INIMUM, M AXIMUM, S UCCESSEUR et P RÉDÉCESSEUR
peuvent être implémentées en temps O(lg n) sur les arbres rouge-noir, puisqu’elles
peuvent s’exécuter en temps O(h) sur un arbre de recherche de hauteur h (comme on
l’a vu au chapitre 12) et qu’un arbre rouge-noir à n nœuds est un arbre de recherche
de hauteur O(lg n). (Bien évidemment, les références à NIL dans les algorithmes du
chapitre 12 devraient être remplacées par nil[T].) Bien que les algorithmes A RBRE I NSÉRER et A RBRE -S UPPRIMER du chapitre 12 s’exécutent en temps O(lg n) quand
on leur fournit un arbre rouge-noir en entrée, les opérations I NSÉRER et S UPPRIMER
d’ensemble dynamique ne garantissent pas que l’arbre binaire de recherche modifié
par elles restera un arbre rouge-noir. Nous verrons cependant aux sections 13.3 et 13.4
que ces deux opérations peuvent être effectivement gérées avec un temps O(lg n).
Exercices
13.1.1 En prenant modèle sur la figure 13.1(a), dessiner l’arbre binaire de recherche complet
de hauteur 3 contenant les clés {1, 2, . . . , 15}. Ajouter les feuilles NIL et colorier les nœuds
de trois manières différentes, de telle façon que les hauteurs noires des arbres rouge-noir
résultants soient 2, 3 et 4.
13.1.2 Dessiner l’arbre rouge-noir qui résulte de l’appel à A RBRE -I NSÉRER sur l’arbre de la
figure 13.1 avec la valeur de clé 36. Si le nœud inséré est colorié en rouge, est-ce que l’arbre
résultant est encore un arbre rouge-noir ? Et si le nœud est colorié en noir ?
13.2
Rotation
271
13.1.3 Un arbre rouge-noir relâché est un arbre binaire de recherche qui satisfait aux propriétés rouge-noir 1, 3, 4 et 5. Autrement dit, la racine peut être rouge ou noire. Soit un arbre
rouge-noir relâché T dont la racine est rouge. Si on colorie la racine de T en noir sans faire
d’autre changement, est-ce que l’arbre résultant est un arbre rouge-noir ?
13.1.4 Supposons que l’on « absorbe » chaque nœud rouge d’un arbre rouge-noir dans son
parent noir, de façon que les enfants du nœud rouge deviennent des enfants du parent noir.
(On ne se préoccupe pas de ce qu’il advient des clés.) Quels sont les degrés possibles d’un
nœud noir après absorption de tous ses enfants rouges ? Que peut-on dire des profondeurs des
feuilles de l’arbre résultant ?
13.1.5 Montrer que le chemin simple le plus long reliant un nœud x d’un arbre rouge-noir à
une feuille a une longueur qui est au plus égale à deux fois celle du plus court chemin simple
reliant le nœud x à une feuille.
13.1.6 Quel est le plus grand nombre possible de nœuds internes d’un arbre rouge-noir de
hauteur noire k ? Quel est le plus petit nombre possible ?
13.1.7 Décrire un arbre rouge-noir à n clés qui possède le ratio maximal entre nœud internes
rouges et nœud internes noirs. Quel est ce ratio ? Quel est l’arbre qui a le ratio minimal, et
quel est ce ratio ?
13.2 ROTATION
c Dunod – La photocopie non autorisée est un délit
Les opérations d’arbre de recherche A RBRE -I NSÉRER et A RBRE -S UPPRIMER,
quand on les exécute sur un arbre rouge-noir à n clés, prennent un temps O(lg n).
Comme elles modifient l’arbre, le résultat pourrait violer les propriétés d’arbre
rouge-noir énumérées à la section 13.1. Pour restaurer ces propriétés, il faut changer les couleurs de certains nœuds de l’arbre et également modifier la chaîne des
pointeurs.
ROTATION-GAUCHE (T, x)
x
α
y
β
y
γ
γ
x
ROTATION-DROITE (T, y)
α
β
Figure 13.2 Les opérations de rotation sur un arbre de recherche binaire. L’opération ROTATION G AUCHE(T , x) transforme la configuration des deux nœuds de gauche pour aboutir à celle de
droite, en modifiant un nombre constant de pointeurs. La configuration de droite peut être
transformée en celle de gauche par l’opération inverse ROTATION -D ROITE(T , y). Les deux nœuds
peuvent se trouver n’importe où dans l’arbre binaire de recherche. Les lettres a, b et g représentent des sous-arbres arbitraires. Une opération de rotation préserve la propriété d’arbre binaire de recherche : les clés de a précèdent clé[x], qui précède les clés de b, qui précède clé[y],
qui précède les clés de g.
13 • Arbres rouge-noir
272
On modifie la chaîne des pointeurs via une rotation, qui est une opération locale
d’arbre de recherche qui préserve la propriété d’arbre binaire de recherche. La figure 13.2 montre les deux sortes de rotation : les rotations gauches et les rotations
droites. Lorsqu’on effectue une rotation gauche sur un nœud x, on suppose que son
enfant de droite y n’est pas nil[T] ; x peut être un nœud quelconque de l’arbre dont
l’enfant de droite n’est pas nil[T]. La rotation gauche fait « pivoter » autour du lien qui
relie x et y. Elle fait ensuite de y la nouvelle racine du sous-arbre, avec x qui devient
l’enfant de gauche de y et l’enfant de gauche de y qui devient l’enfant de droite de x.
Le pseudo code de ROTATION -G AUCHE suppose que droite[x] fi nil[T] et que le
parent de la racine est nil[T].
ROTATION -G AUCHE(T, x)
1 y ← droite[x]
2 droite[x] ← gauche[y]
initialise y.
sous-arbre gauche de y devient
sous-arbre droit de x.
3 si gauche[y] fi nil[T]
4
alors p[gauche[y]] ← x
5
p[y] ← p[x]
relie parent de x à y.
6
si p[x] = nil[T]
7
alors racine[T] ← y
8
sinon si x = gauche[p[x]]
9
alors gauche[p[x]] ← y
10
sinon droite[p[x]] ← y
11
gauche[y] ← x
place x à gauche de y.
12
p[x] ← y
La figure 13.3 montre l’action de ROTATION -G AUCHE. Le code de ROTATION D ROITE est symétrique. Les deux procédures s’exécutent en temps O(1). Les
pointeurs sont les seuls champs du nœud qui sont modifiés par une rotation.
Exercices
13.2.1 Écrire le pseudo code de ROTATION -D ROITE.
13.2.2 Montrer que, dans tout arbre binaire de recherche à n nœuds, il existe exactement
n − 1 rotations possibles.
13.2.3 Soient a, b et c des nœuds arbitraires des sous-arbres a, b et g (respectivement) de
l’arbre de gauche de la figure 13.2. De quelle manière les profondeurs de a, b et c sont-elles
modifiées lorsqu’on effectue une rotation gauche sur le nœud x de la figure ?
13.2.4 Montrer que tout arbre binaire de recherche à n nœuds peut être transformé en n’importe quel autre arbre binaire de recherche à n nœuds, à l’aide de O(n) rotations. (Conseil :
Commencer par montrer qu’au plus n − 1 rotations droite suffisent à transformer l’arbre en
une chaîne orientée vers la droite.)
13.3
Insertion
273
7
4
3
11 x
6
9
18 y
2
14
12
ROTATION-GAUCHE (T, x)
19
17
22
20
7
4
3
2
18 y
6
x 11
9
19
14
12
22
17
20
Figure 13.3 Un exemple de modification d’arbre binaire de recherche par la procédure
ROTATION -G AUCHE(T , x). La liste des valeurs de clé produite par un parcours infixe est la même
pour l’arbre d’entrée que pour l’arbre modifié.
13.2.5 On dit qu’un arbre binaire de recherche T1 peut être converti à droite pour donner
un arbre binaire de recherche T2 s’il est possible d’obtenir T2 à partir de T1 via une série
d’appels à ROTATION -D ROITE. Donner un exemple de deux arbres T1 et T2 tels que T1 ne
peut pas être converti à droite pour donner T2 . Montrer ensuite que, si un arbre T1 peut être
converti à droite pour donner T2 , il peut être converti à droite à l’aide de O(n2 ) appels à
ROTATION -D ROITE.
c Dunod – La photocopie non autorisée est un délit
13.3 INSERTION
L’insertion d’un nœud dans un arbre rouge-noir à n nœuds peut se faire en temps
O(lg n). Nous utiliserons une version légèrement modifiée de la procédure A RBRE I NSÉRER (section 12.3) pour insérer le nœud z dans l’arbre T comme si c’était un
arbre binaire ordinaire, puis nous colorierons z en rouge. Pour garantir la préservation des propriétés rouge-noir, nous appellerons ensuite la procédure auxiliaire
RN-I NSÉRER -C ORRECTION pour recolorier des nœuds et faire des rotations. L’appel RN-I NSÉRER(T, z) insère le nœud z, dont le champ clé est censé avoir été déjà
rempli, dans l’arbre rouge-noir T.
RN-I NSÉRER(T, z)
1 y ← nil[T]
2 x ← racine[T]
3 tant que x fi nil[T]
4
faire y ← x
5
si clé[z] < clé[x]
6
alors x ← gauche[x]
7
sinon x ← droite[x]
13 • Arbres rouge-noir
274
8
9
10
11
12
13
14
15
16
17
p[z] ← y
si y = nil[T]
alors racine[T] ← z
sinon si clé[z] < clé[y]
alors gauche[y] ← z
sinon droite[y] ← z
gauche[z] ← nil[T]
droite[z] ← nil[T]
couleur[z] ← ROUGE
RN-I NSÉRER -C ORRECTION(T, z)
Il y a quatre différences entre les procédures A RBRE -I NSÉRER et RN-I NSÉRER.
Primo, toutes les instances de NIL dans A RBRE -I NSÉRER sont remplacés par nil[T].
Secundo, on initialise gauche[z] et droite[z] à nil[T] aux lignes 14–15 de RNI NSÉRER, de façon à conserver la bonne structure d’arbre. Tertio, on colorie z en
rouge à la ligne 16. Quarto, comme le coloriage de z en rouge risque de créer une violation d’une des propriétés rouge-noir, on appelle RN-I NSÉRER -C ORRECTION(T, z)
en ligne 17 de RN-I NSÉRER pour restaurer les propriétés rouge-noir.
RN-I NSÉRER -C ORRECTION(T, z)
1 tant que couleur[p[z]] = ROUGE
2
faire si p[z] = gauche[p[p[z]]]
3
alors y ← droite[p[p[z]]]
4
si couleur[y] = ROUGE
5
alors couleur[p[z]] ← NOIR
6
couleur[y] ← NOIR
7
couleur[p[p[z]]] ← ROUGE
8
z ← p[p[z]]
9
sinon si z = droite[p[z]]
10
alors z ← p[z]
11
ROTATION -G AUCHE(T, z)
12
couleur[p[z]] ← NOIR
13
couleur[p[p[z]]] ← ROUGE
14
ROTATION -D ROITE(T, p[p[z]])
15
sinon (idem clause alors avec
permutation de « droite » et « gauche »)
16 couleur[racine[T]] ← NOIR
Cas 1
Cas 1
Cas 1
Cas 1
Cas 2
Cas 2
Cas 3
Cas 3
Cas 3
Pour comprendre le fonctionnement de RN-I NSÉRER -C ORRECTION, nous allons
diviser notre étude du code en trois grandes phases. Primo, nous allons voir quelles
sont les violations de propriété rouge-noir qui se produisent dans RN-I NSÉRER quand
le nœud z est inséré et colorié en rouge. Secundo, nous allons examiner le but général
de la boucle tant que des lignes 1–15. Enfin, nous étudierons chacun des trois cas (1)
(1) Le cas 2 se ramène au cas 3, et ces deux cas ne sont donc pas mutuellement exclusifs.
13.3
Insertion
275
constituant le corps de la boucle tant que et verrons comment ils fonctionnent. La figure 13.4 montre le fonctionnement de RN-I NSÉRER -C ORRECTION sur un exemple
d’arbre rouge-noir.
11
2
(a)
14
1
7
15
5
z
8 y
4
Cas 1
11
2
(b)
14 y
1
7
5
z
15
8
4
Cas 2
11
7
(c)
z
14 y
2
8
1
15
5
Cas 3
4
7
z
c Dunod – La photocopie non autorisée est un délit
(d)
2
11
1
5
4
8
14
15
Figure 13.4 Fonctionnement de RN-I NSÉRER -C ORRECTION. (a) Un nœud z après insertion.
Comme z et son parent p[z] sont tous les deux rouges, il y a violation de la propriété 4. Comme
l’oncle de z, y, est rouge, on est dans le cas 1 du code. Les nœuds sont recoloriés et le pointeur z
remonte dans l’arbre, ce qui donne l’arbre montré en (b). Ici aussi, z et son parent sont tous les
deux rouges, mais l’oncle de z, y, est noir. Comme z est l’enfant de droite de p[z], on est dans le
cas 2. On effectue une rotation gauche, et l’arbre résultant est montré en (c). Maintenant z est
l’enfant de gauche de son parent, et l’on est dans le cas 3. Une rotation droite produit l’arbre de
(d), qui est un arbre rouge-noir correct.
13 • Arbres rouge-noir
276
Quelle est celle des propriétés rouge-noir qui risque d’être violée lors de l’appel
à RN-I NSÉRER -C ORRECTION ? La propriété 1 reste certainement vraie, de même
que la propriété 3, car les deux enfants du nœud rouge nouvellement inséré sont la
sentinelle nil[T]. La propriété 5, qui dit que le nombre de nœuds noirs est le même
sur tous les chemins partant d’un nœud donné, est vérifiée elle aussi, car le nœud
z remplace la sentinelle (noire) et le nœud z est rouge avec des enfants sentinelle.
Donc, les seules propriétés susceptibles d’être violées sont la propriété 2, qui impose
à la racine d’être noire, et la propriété 4, qui dit qu’un nœud rouge ne peut pas avoir
d’enfant rouge. Ces deux infractions possibles sont dues au coloriage de z en rouge.
La propriété 2 est violée si z est la racine, et la propriété 4 est violée si le parent de z
est rouge. La figure 13.4(a) montre une infraction à la propriété 4 après que le nœud
z a été inséré.
La boucle tant que des lignes 1–15 conserve l’invariant tripartite suivant :
Au début de chaque itération de la boucle,
a) Le nœud z est rouge.
b) Si p[z] est la racine, alors p[z] est noir.
c) S’il y a violation des propriétés rouge-noir, il y a une violation au plus, et
c’est une violation de la propriété 2 ou de la propriété 4. S’il y a violation
de la propriété 2, cela vient de ce que z est la racine et est rouge. S’il y a
violation de la propriété 4, cela vient de ce que z et p[z] sont tous les deux
rouges.
La partie (c), qui traite des violations des propriétés rouge-noir, est plus cruciale, pour
montrer que RN-I NSÉRER -C ORRECTION restaure les propriétés rouge-noir, que les
parties (a) et (b), qui nous serviront à l’occasion à comprendre des situations du code.
Comme nous allons nous concentrer sur le nœud z et les nœuds proches de celuici dans l’arbre, il sera utile de savoir que, d’après la partie (a), z est rouge. Nous
utiliserons la partie (b) pour montrer que le nœud p[p[z]] existe quand on le référence
aux lignes 2, 3, 7, 8, 13 et 14.
N’oubliez pas qu’il faut montrer qu’un invariant de boucle est vrai avant la première itération de la boucle, que chaque itération conserve l’invariant, et que l’invariant fournit une propriété utile à la fin de l’exécution de la boucle.
Commençons par l’initialisation et la fin de l’exécution. Ensuite, quand nous étudierons en détail le fonctionnement du corps de la boucle, nous démontrerons que
la boucle conserve l’invariant à chaque itération. Au passage, nous démontrerons
également qu’il y a deux possibilités à chaque itération de la boucle : le pointeur z
remonte dans l’arbre, ou bien il y a des rotations qui se font et la boucle se termine.
Initialisation : Avant la première itération de la boucle, on avait un arbre rougenoir sans violations auquel on avait ajouté un nœud rouge z. On va montrer que
13.3
Insertion
277
chaque partie de l’invariant est vraie au moment où est appelée RN-I NSÉRER C ORRECTION :
a) Quand RN-I NSÉRER -C ORRECTION est appelée, z est le nœud rouge qui avait
été ajouté.
b) Si p[z] est la racine, alors p[z] avait commencé noir et n’avait pas changé avant
l’appel de RN-I NSÉRER -C ORRECTION.
c) Nous avons déjà vu que les propriétés 1, 3 et 5 sont vérifiées quand RN-I NSÉRER C ORRECTION est appelée.
S’il y a violation de la propriété 2, alors la racine rouge est forcément le nœud
nouvellement ajouté z, qui est le seul nœud interne de l’arbre. Comme le parent
et les deux enfants de z sont la sentinelle, qui est noire, il n’y a pas violation de la
propriété 4. Donc, la violation de la propriété 2 est la seule violation des propriétés
rouge-noir dans tout l’arbre.
S’il y a violation de la propriété 4, alors, comme les enfants du nœud z sont des
sentinelles noires et que l’arbre était correct avant l’ajout de z, la violation provient
forcément de ce que z et p[z] sont rouges. En outre, il n’y a pas d’autres violations
des propriétés rouge-noir.
c Dunod – La photocopie non autorisée est un délit
Terminaison : Quand la boucle se termine, elle le fait parce que p[z] est noir. (Si z
est la racine, alors p[z] est la sentinelle nil[T] qui est noire.) Donc, il n’y a pas
violation de la propriété 4 après exécution de la boucle. D’après l’invariant de
boucle, la seule propriété qui pourrait ne pas être vérifiée est la propriété 2. La
ligne 16 restaure cette propriété, elle aussi, de sorte que, quand RN-I NSÉRER C ORRECTION prend fin, toutes les propriétés rouge-noir sont vérifiées.
Conservation : Il y a en fait six cas à considérer dans la boucle tant que, mais
trois d’entre eux sont symétriques par rapport aux trois autres, selon que le parent
de z, p[z], est un enfant gauche ou droite du grand-parent de z, p[p[z]], ce qui
est déterminé en ligne 2. Nous ne donnons le code que pour le cas où p[z] est
un enfant gauche. Le nœud p[p[z]] existe, car d’après la partie (b) de l’invariant
de boucle, si p[z] est la racine, alors p[z] est noir. Comme on n’entre dans une
itération de la boucle que si p[z] est rouge, on sait que p[z] ne peut pas être la
racine. Donc, p[p[z]] existe.
Le cas 1 se distingue des cas 2 et 3 par la couleur de l’« oncle » (frère du parent)
de z. La ligne 3 fait pointer y vers l’oncle de z, droite[p[p[z]]], et un test est fait
en ligne 4. Si y est rouge, alors on est dans le cas 1. Sinon, le contrôle passe aux
cas 2 et 3. Dans les trois cas de figure, le grand-parent de z, p[p[z]], est noir ; en
effet, le parent p[z] est rouge et la propriété 4 n’est violée qu’entre z et p[z].
➤ Cas 1 : l’oncle de z, y, est rouge
La figure 13.3 montre la situation du cas 1 (lignes 5–8). Le cas 1 se produit quand
p[z] et y sont tous les deux rouges. Comme p[p[z]] est noir, on peut colorier p[z]
et y en noir, corrigeant ainsi le problème d’avoir z et p[z] rouges tous les deux,
13 • Arbres rouge-noir
278
et colorier p[p[z]] en rouge, préservant ainsi la propriété 5. On répète ensuite la
boucle tant que avec p[p[z]] faisant office de nouveau nœud z. Le pointeur z
remonte de deux crans dans l’arbre.
new z
C
(a)
A
A
D y
α
δ
B z
β
ε
D
α
γ
β
α
γ
A
β
δ
C
B
D y
ε
α
D
γ
A
ε
γ
new z
B
z
δ
B
C
(b)
C
δ
ε
β
Figure 13.5 Cas 1 de la procédure RN-I NSÉRER. La propriété 4 est violée, car z et son
parent p[z] sont tous les deux rouges. On effectue la même action, que (a) z soit un enfant
droite ou (b) que z soit un enfant gauche. Chacun des sous-arbres a, b, g, d et ´ a une
racine noire, et chacun a la même hauteur noire. Le code du cas 1 change les couleurs
de certains nœuds, pour préserver la propriété 5 : tous les chemins qui descendent d’un
nœud vers une feuille ont le même nombre de noirs. La boucle tant que continue en
prenant le grand-parent du nœud z, p[p[z]], comme nouveau z. Il ne peut maintenant y
avoir violation de la propriété 4 qu’entre le nouveau z, qui est rouge, et son parent, si ce
dernier est rouge lui aussi.
Nous allons montrer que le cas 1 conserve l’invariant de boucle au début de l’itération suivante. z désignera le nœud z dans l’itération courante, et z = p[p[z]]
désignera le nœud z dans le test fait en ligne 1 lors de l’itération suivante.
a) Comme cette itération colorie p[p[z]] en rouge, le nœud z est rouge au début de
l’itération suivante.
b) Le nœud p[z ] est p[p[p[z]]] dans cette itération, et la couleur de ce nœud ne
change pas. Si ce nœud est la racine, il était noir avant cette itération, et il reste
noir au début de l’itération suivante.
c) Nous avons déjà prouvé que le cas 1 préserve la propriété 5, et il est manifeste
qu’il n’introduit pas de violation des propriétés 1 ou 3.
Si le nœud z est la racine au début de l’itération suivante, alors le cas 1 avait corrigé l’unique violation, celle de la propriété 4, dans l’itération courante. Comme
z est rouge et que c’est la racine, la propriété 2 devient la seule à être violée, et
cette violation est due à z .
Si le nœud z n’est pas la racine au début de l’itération suivante, alors le cas 1 n’a
pas créé de violation de la propriété 2. Le cas 1 avait corrigé l’unique violation,
celle de la propriété 4, qui existait au début de l’itération courante. Il avait ensuite
13.3
Insertion
279
colorié z en rouge et laissé p[z ] inchangé. Si p[z ] était noir, il n’y a pas violation
de la propriété 4. Si p[z ] était rouge, le coloriage de z en rouge avait créé une
seule violation de la propriété 4 entre z et p[z ].
➤ Cas 2 : l’oncle de z, y, est noir et z est un enfant droite
➤ Cas 3 : l’oncle de z, y, est noir et z est un enfant gauche
Dans les cas 2 et 3, la couleur de l’oncle de z, y, est le noir. Les deux cas se
distinguent par le fait que z est un enfant droite ou un enfant gauche de p[z]. Les
lignes 10–11 constituent le cas 2, illustré à la figure 13.6 en même temps que
le cas 3. Dans le cas 2, le nœud z est un enfant droite de son parent. On utilise
immédiatement une rotation gauche pour obtenir le cas 3 (lignes 12–14), dans
lequel le nœud z est un enfant gauche. Comme z et p[z] sont tous les deux rouges,
la rotation n’affecte ni la hauteur noire des nœuds, ni la propriété 5. Que l’on
entre dans le cas 3 directement ou par l’intermédiaire du cas 2, l’oncle de z, y, est
noir, car autrement on aurait exécuté le cas 1. De plus, le nœud p[p[z]] existe, car
on a démontré que ce nœud existait au moment où les lignes 2 et 3 avaient été
exécutées, et après avoir remonté z d’un niveau en ligne 10 puis l’avoir descendu
d’un niveau en ligne 11, l’identité de p[p[z]] reste telle quelle. Dans le cas 3,
on exécute des changements de couleur et une rotation droite, ce qui préserve la
propriété 5 ; ensuite, comme on n’a plus deux nœuds rouges d’affilée, on a fini.
Le corps de la boucle tant que n’est plus exécuté une nouvelle fois, vu que p[z]
est maintenant noir.
C
δ y
A
α
z
γ
α
c Dunod – La photocopie non autorisée est un délit
Cas 2
δ y
B
B z
β
B
C
γ
A
z
α
A
C
β
γ
δ
β
Cas 3
Figure 13.6 Cas 2 et 3 de la procédure RN-I NSÉRER. Comme dans le cas 1, la propriété 4
est violée dans le cas 2 ou le cas 3 car z et son parent p[z] sont tous les deux rouges.
Chacun des sous-arbres a, b, g et d a une racine noire (a, b et g d’après la propriété 4,
et d car autrement on serait dans le cas 1), et chacun a la même hauteur noire. Le cas 2
devient cas 3 suite à une rotation gauche, ce qui préserve la propriété 5 : tous les chemins
qui descendent d’un nœud vers une feuille ont le même nombre de noirs. Le cas 3 entraîne
des changements de couleur et une rotation droite, ce qui a pour effet de préserver aussi
la propriété 5. La boucle tant que se termine ensuite, car la propriété 4 est vérifiée : il n’y
a plus deux nœuds rouges d’affilée.
Montrons maintenant que les cas 2 et 3 conservent l’invariant de boucle. (Comme
nous venons de le démontrer, p[z] sera noir lors du prochain test fait en ligne 1, et
il n’y aura pas d’autre exécution du corps de la boucle.)
280
13 • Arbres rouge-noir
a) Le cas 2 fait pointer z vers p[z], qui est rouge. IL n’y a pas d’autre changement,
concernant z ou sa couleur, dans les cas 2 et 3.
b) Le cas 3 rend p[z] noir de façon que, si p[z] est la racine au début de l’itération
suivante, la racine soit noire.
c) Comme dans le cas 1, les propriétés 1, 3 et 5 sont préservées dans les cas 2 et 3.
Comme le nœud z n’est pas la racine dans les cas 2 et 3, on sait qu’il n’y a pas
violation de la propriété 2. Les cas 2 et 3 n’introduisent aucune violation de la
propriété 2, car le seul nœud qui est colorié en rouge devient un enfant d’un nœud
noir suite à la rotation du cas 3.
Les cas 2 et 3 corrigent l’unique violation, celle de la propriété 4, et ne créent
aucune autre violation.
Ayant montré que chaque itération de la boucle conserve l’invariant, nous avons montré que RN-I NSÉRER -C ORRECTION restaure effectivement les propriétés rouge-noir.
a) Analyse
Quel est le temps d’exécution de RN-I NSÉRER ? Comme la hauteur d’un arbre rougenoir à n nœuds est O(lg n), les lignes 1–16 de RN-I NSÉRER prennent un temps
O(lg n). Dans RN-I NSÉRER -C ORRECTION, la boucle tant que ne se répète que si
c’est le cas 1 qui est exécuté, puis le pointeur z remonte de deux niveaux dans l’arbre.
Le nombre total de fois que la boucle tant que peut être exécutée est donc O(lg n).
Ainsi, RN-I NSÉRER demande un temps total de O(lg n). Il est intéressant de noter
qu’elle ne fait jamais plus de deux rotations, car la boucle tant que se termine si
c’est le cas 2 ou 3 qui est exécuté.
Exercices
13.3.1 A la ligne ?? de RN-I NSÉRER, on colorie en rouge le nouveau nœud inséré z. Notez
que si nous avions choisi de colorier z en noir, la propriété 4 d’arbre rouge-noir n’aurait pas
été violée. Pourquoi donc ne pas avoir choisi de colorier z en noir ?
13.3.2 Montrer les arbres rouge-noir qui résultent de l’insertion successive des clés 41, 38,
31, 12, 19, 8 dans un arbre rouge-noir initialement vide.
13.3.3 On suppose que la hauteur noire de chacun des sous-arbres a, b, g, d, ´ des figures 13.5
et 13.6 vaut k. Étiqueter chaque nœud, sur chaque figure, par sa hauteur noire pour vérifier
que la propriété 5 est préservée par la transformation indiquée.
13.3.4 Le professeur Muche se fait du souci : il se dit que RN-I NSÉRER -C ORRECTION
risque d’affecter à couleur[nil[T]] la couleur ROUGE, auquel cas le test fait en ligne 1 ne
forcerait pas la boucle à se terminer quand z est la racine. Montrer que le professeur se
fait du souci pour rien, en prouvant que RN-I NSÉRER -C ORRECTION ne met jamais dans
couleur[nil[T]] la valeur ROUGE.
13.4
Suppression
281
13.3.5 Soit un arbre rouge-noir formé par l’insertion de n nœuds via RN-I NSÉRER. Montrer
que, si n > 1, l’arbre a au moins un nœud rouge.
13.3.6 Dire comment on pourrait implémenter RN-I NSÉRER efficacement si la représentation des arbres rouge-noir n’imposait pas de stocker des pointeurs vers parent.
13.4 SUPPRESSION
c Dunod – La photocopie non autorisée est un délit
Comme les autres opérations primitives sur un arbre rouge-noir à n nœuds, la suppression d’un nœud requiert un temps O(lg n). Supprimer un nœud d’un arbre rouge-noir
est cependant légèrement plus compliqué que d’en insérer un.
La procédure RN-S UPPRIMER est une version légèrement modifiée de la procédure A RBRE -S UPPRIMER (section 12.3). Après suppression d’un nœud, elle appelle
une procédure auxiliaire RN-S UPPRIMER -C ORRECTION qui modifie des couleurs et
fait des rotations pour restaurer les propriétés rouge-noir.
RN-S UPPRIMER(T, z)
1 si gauche[z] = nil[T] ou droite[z] = nil[T]
2
alors y ← z
3
sinon y ← A RBRE -S UCCESSEUR(z)
4 si gauche[y] fi nil[T]
5
alors x ← gauche[y]
6
sinon x ← droite[y]
7 p[x] ← p[y]
8 si p[y] = nil[T]
9
alors racine[T] ← x
10
sinon si y = gauche[p[y]]
11
alors gauche[p[y]] ← x
12
sinon droite[p[y]] ← x
13 si y fi z
14
alors clé[z] ← clé[y]
15
copier données satellite de y dans z
16 si couleur[y] = NOIR
17
alors RN-S UPPRIMER -C ORRECTION(T, x)
18 retourner y
Il y a trois différences entre les procédures A RBRE -S UPPRIMER et RN-S UPPRIMER.
Primo, toutes les références à NIL dans A RBRE -S UPPRIMER sont remplacées par des
références à la sentinelle nil[T] dans RN-S UPPRIMER. Secundo, le test déterminant si
x est NIL en ligne 7 de A RBRE -S UPPRIMER a été enlevé, et l’affectation p[x] ← p[y]
est devenue inconditionnelle en ligne 7 de RN-S UPPRIMER. Donc, si x est la sentinelle nil[T], son pointeur de parent pointe vers le parent du nœud supprimé y. Tertio,
un appel à RN-S UPPRIMER -C ORRECTION est fait aux lignes 16–17 si y est noir. Si
282
13 • Arbres rouge-noir
y est rouge, les propriétés rouge-noir restent vérifiées après suppression de y, et ce
pour les raisons suivantes :
– aucune hauteur noire n’a été modifiée dans l’arbre,
– il n’y a pas eu apparition de nœuds rouges adjacents, et
– comme y n’aurait pas pu être la racine s’il était rouge, la racine reste noire.
Le nœud x passé à RN-S UPPRIMER -C ORRECTION est l’un ou l’autre des deux nœuds
suivants : le nœud qui était l’unique enfant de y avant suppression de y si y avait un
enfant qui n’était pas la sentinelle nil[T], ou, si y n’avait pas d’enfants, x est la sentinelle nil[T]. Dans ce dernier cas, l’assignation inconditionnelle de la ligne 7 garantit
que le parent de x est maintenant le nœud qui était précédemment le parent de y, que
x soit un nœud interne porteur de clé ou qu’il soit la sentinelle nil[T].
On peut maintenant regarder comment la procédure RN-S UPPRIMER -C ORRECTION
restaure les propriétés rouge-noir de l’arbre.
RN-S UPPRIMER -C ORRECTION(T, x)
1 tant que x fi racine[T] et couleur[x] = NOIR
2
faire si x = gauche[p[x]]
3
alors w ← droite[p[x]]
4
si couleur[w] = ROUGE
5
alors couleur[w] ← NOIR
Cas 1
Cas 1
6
couleur[p[x]] ← ROUGE
Cas 1
7
ROTATION -G AUCHE(T, p[x])
8
w ← droite[p[x]]
Cas 1
9
si couleur[gauche[w]] = NOIR et couleur[droite[w]] = NOIR
10
alors couleur[w] ← ROUGE
Cas 2
11
x ← p[x]
Cas 2
12
sinon si couleur[droite[w]] = NOIR
13
alors couleur[gauche[w]] ← NOIR Cas 3
Cas 3
14
couleur[w] ← ROUGE
Cas 3
15
ROTATION -D ROITE(T, w)
16
w ← droite[p[x]]
Cas 3
17
couleur[w] ← couleur[p[x]]
Cas 4
Cas 4
18
couleur[p[x]] ← NOIR
Cas 4
19
couleur[droite[w]] ← NOIR
Cas 4
20
ROTATION -G AUCHE(T, p[x])
21
x ← racine[T]
Cas 4
22
sinon (idem clause alors avec « droite » et « gauche » échangés)
23 couleur[x] ← NOIR
Si le nœud supprimé y dans RN-S UPPRIMER est noir, il peut se produire trois
problèmes. Primo, si y était la racine et qu’un enfant rouge de y devient la nouvelle
racine, on enfreint la propriété 2. Secundo, si x et p[y] (qui maintenant est aussi p[x])
13.4
Suppression
283
étaient tous les deux rouges, alors on a enfreint la propriété 4. Tertio, la suppression de
y entraîne que chaque chemin qui contenait y se retrouve avec un nœud noir en moins.
Donc, la propriété 5 est désormais enfreinte par tous les ancêtres de y dans l’arbre. On
peut corriger ce problème en disant que le nœud x a un noir « supplémentaire ». C’està-dire, si on ajoute 1 au compteur de nœuds noirs de chaque chemin contenant x, alors
moyennant cette interprétation, la propriété 5 tient toujours. Quand on supprime le
nœud noir y, on « pousse » sa noirceur sur son enfant. Le problème est que maintenant
le nœud x n’est ni rouge ni noir, ce qui est une violation de la propriété 1. À la place,
le nœud x est « doublement noir » ou « rouge-et-noir » ; selon le cas, il contribue pour
2 ou 1 au compteur de nœuds noirs d’un chemin contenant x. L’attribut couleur de x
reste ROUGE (si x est rouge-et-noir) ou NOIR (si x est doublement noir). Autrement
dit, le noir supplémentaire d’un nœud se reflète au niveau du pointage de x vers le
nœud plutôt qu’au niveau de l’attribut couleur.
La procédure RN-S UPPRIMER -C ORRECTION restaure les propriétés 1, 2 et 4. Les
exercices 13.4.1 et 13.4.2 vous demanderont de montrer que la procédure restaure les
propriétés 2 et 4 ; donc, dans le reste de cette section, nous nous concentrerons sur la
propriété 1. Le but de la boucle tant que des lignes 1–22 est de faire remonter le noir
supplémentaire dans l’arbre jusqu’à ce que
1) x pointe vers un nœud rouge-et-noir, auquel cas on colorie x en noir (simple) en
ligne 23,
2) x pointe vers la racine, auquel cas on peut se contenter de « supprimer » le noir en
trop, ou
3) on peut procéder à des rotations et recoloriages idoines.
c Dunod – La photocopie non autorisée est un délit
Au sein de la boucle tant que, x pointe toujours vers un nœud doublement noir qui
n’est pas la racine. On détermine en ligne 2 si x est un enfant gauche ou un enfant
droite de son parent p[x]. (Nous avons donné le code pour le cas où x est un enfant
gauche ; le cas où x est un enfant droite, en ligne 22, est symétrique.) On gère un
pointeur w vers le frère de x. Comme le nœud x est doublement noir, le nœud w ne
peut pas être nil[T] ; sinon, le nombre de noirs du chemin allant de p[x] à la feuille
(noire simple) w serait inférieur au nombre du chemin reliant p[x] à x.
Les quatre cas (2) du code sont illustrés à la figure 13.7. Avant d’examiner chaque
cas en détail, voyons de manière plus générale comment on peut vérifier que la transformation dans chacun des cas préserve la propriété 5. L’idée fondamentale est que,
dans chaque cas, le nombre de nœuds noirs (dont le noir supplémentaire de x) partant de (et incluant) la racine du sous-arbre dessiné pour chacun des sous-arbres
a, b, . . . , z est préservé par la transformation. Donc, si la propriété 5 est vérifiée
avant la transformation, elle l’est après. Par exemple, sur la figure 13.7(a), qui illustre
le cas 1, le nombre de nœuds noirs entre la racine et l’un des sous-arbres a ou b
vaut 3, avant comme après la transformation. (Rappelons une fois de plus que le
(2) Comme dans RN-I NSÉRER -C ORRECTION, les cas de RN-S UPPRIMER -C ORRECTION ne sont pas mutuellement exclusifs.
13 • Arbres rouge-noir
284
Cas 1
B
(a)
x A
α
B
D w
β
C
γ
x A
E
δ
ε
ζ
x A
α
C
γ
nouveau x
ε
C
γ
B c
D
α
β
ζ
C
δ
ζ
C nouveau w
x A
ε
ε
B c
α
E
δ
E
γ
D w
β
ζ
δ
Cas 3
x A
α
γ
β
E
δ
ε
A
B c
(c)
α
D w
β
E
nouveau w C
Cas 2
B c
(b)
D
β
γ
D
ζ
δ
E
ε
Cas 4
B c
(d)
x A
α
D c
B
D w
β
C
γ
c′
δ
E
A
E
ε
ζ
ζ
α
C
β
γ
c′ ε
ζ
nouveau x = racine [T]
δ
Figure 13.7 Les cas dans la boucle tant que de la procédure RN-S UPPRIMER -C ORRECTION. Les
nœuds en noir ont des attributs couleur valant NOIR, les nœuds en gris foncé ont des attributs
couleur valant ROUGE et les nœuds en gris clair ont des attributs couleur, représentés par c et c ,
qui peuvent valoir ROUGE ou NOIR. Les lettres a, b, . . . , z représentent des sous-arbres arbitraires.
Dans chaque cas, la configuration de gauche est transformée en la configuration de droite via
modification de certaines couleurs et/ou exécution d’une rotation. Chaque nœud pointé par x
a un noir supplémentaire et est doublement noir ou rouge-et-noir. Le seul cas qui provoque la
répétition de la boucle est le cas 2. (a) Le cas 1 est transformé en cas 2, 3 ou 4 via permutation
des couleurs des nœuds B et D et exécution d’une rotation gauche. (b) Dans le cas 2, le noir
supplémentaire représenté par le pointeur x est déplacé vers le haut via coloriage du nœud D en
rouge et configuration de x pour le faire pointer vers le nœud B. Si l’on entre dans le cas 2 via le
cas 1, la boucle tant que se termine car le nouveau nœud x est rouge-et-noir, et donc la valeur c de
son attribut couleur est ROUGE. (c) Le cas 3 est transformé en cas 4 via permutation des couleurs
des nœuds C et D et exécution d’une rotation droite. (d) Dans le cas 4, le noir supplémentaire
représenté par x peut être supprimé via modification de certaines couleurs et exécution d’une
rotation gauche (sans violation des propriétés rouge-noir), et la boucle se termine.
13.4
Suppression
285
nœud x compte pour un noir de plus.) De même, le nombre de nœuds noirs entre
la racine et l’un des arbres g, d, ´ et z vaut 2, avant comme après la transformation. À la figure 13.7(b), le comptage doit tenir compte de la valeur c de l’attribut
couleur de la racine du sous-arbre affiché, qui peut être ROUGE ou NOIR. Si l’on définit count(ROUGE) = 0 et count(NOIR) = 1, alors le nombre de nœuds noirs entre
la racine et a est 2 + count(c), avant comme après la transformation. Dans ce cas,
après la transformation, le nouveau nœud x a l’attribut couleur c, mais ce nœud est
soit rouge-et-noir (si c = ROUGE) ou doublement noir (si c = NOIR). On peut vérifier
les autres cas de façon similaire (voir exercice 13.4.5).
➤ Cas 1 : le frère de x, w, est rouge
Le cas 1 (lignes 5–8 de RN-S UPPRIMER -C ORRECTION et figure 13.7(a)) se produit
quand le nœud w, le frère de x, est rouge. Comme w doit avoir des enfants noirs, on
peut permuter les couleurs de w et p[x] puis effectuer une rotation gauche sur p[x]
sans enfreindre l’une des propriétés rouge-noir. Le nouveau frère de x, qui est l’un
des enfants de w avant la rotation, est désormais noir, et donc le cas 1 est ramené au
cas 2, 3 ou 4.
Les cas 2, 3 et 4 se produisent quand le nœud w est noir ; ces cas se distinguent par
les couleurs des enfants de w.
➤ Cas 2 : le frère de x, w, est noir, et les deux enfants de w sont noirs
c Dunod – La photocopie non autorisée est un délit
Dans le cas 2 (lignes 10–11 de RN-S UPPRIMER -C ORRECTION et figure 13.7(b)), les
deux enfants de w sont noirs. Comme w est noir lui aussi, on enlève un noir à x et à w,
ce qui laisse à x un seul noir et qui laisse w rouge. Pour compenser la suppression d’un
noir dans x et w, on voudrait ajouter un noir supplémentaire à p[x], qui était au départ
rouge ou noir. Pour ce faire, on répète la boucle tant que en faisant de p[x] le nouveau
nœud x. Observez que, si l’on entre dans le cas 2 via le cas 1, le nouveau nœud x
est rouge-et-noir, vu que le p[x] originel était rouge. Donc, la valeur c de l’attribut
couleur du nouveau nœud x est ROUGE, et la boucle se termine quand elle teste la
condition de boucle. Le nouveau nœud x est alors colorié en noir (simple), en ligne 23.
➤ Cas 3 : le frère de x, w, est noir, l’enfant gauche de w est rouge et l’enfant
droite de w est noir
Le cas 3 (lignes 13–16 et figure 13.7(c)) se produit quand w est noir, son enfant
gauche est rouge et son enfant droite est noir. On peut permuter les couleurs de w et
de son enfant gauche gauche[w] puis faire une rotation droite sur w sans enfreindre
les propriétés rouge-noir. Le nouveau frère w de x est maintenant un nœud noir avec
un enfant droite rouge, et le cas 3 est donc ramené au cas 4.
➤ Cas 4 : le frère de x, w, est noir et l’enfant droite de w est rouge
Le cas 4 (lignes 17–21 et figure 13.7(d)) se produit quand le frère du nœud x est noir
et que l’enfant droite de w est rouge. En faisant des modifications de couleur et en
286
13 • Arbres rouge-noir
effectuant une rotation gauche sur p[x], on peut supprimer le noir en trop de x, rendant
ce dernier noir simple, sans violer de propriété rouge-noir. Faire de x la racine forcera
la boucle tant que à se terminer quand elle testera la condition de bouclage.
a) Analyse
Quel est le temps d’exécution de RN-S UPPRIMER ? Comme la hauteur d’un arbre
rouge-noir à n nœuds est O(lg n), le coût total de la procédure, hors appel à RNS UPPRIMER -C ORRECTION, est O(lg n). À l’intérieur de RN-S UPPRIMER -C OR RECTION , les cas 1, 3 et 4 se terminent chacun après avoir effectué un nombre
constant de changements de couleur et au plus trois rotations. Le cas 2 est le seul
pour lequel la boucle tant que peut se répéter ; dans ce cas, le pointeur x remonte
dans l’arbre au plus O(lg n) fois, et aucune rotation n’est effectuée. La procédure
RN-S UPPRIMER -C ORRECTION prend donc un temps O(lg n) et effectue au plus trois
rotations ; le coût total de RN-S UPPRIMER est donc également O(lg n).
Exercices
13.4.1 Montrer que, après exécution de RN-S UPPRIMER -C ORRECTION, la racine de l’arbre
est forcément noire.
13.4.2 Montrer que, si dans RN-S UPPRIMER x et p[y] sont rouges, alors la propriété 4 est
restaurée par l’appel RN-S UPPRIMER -C ORRECTION (T, x).
13.4.3 Dans l’exercice 13.3.2, on vous demandait quel était l’arbre issu de l’insertion successive des clés 41, 38, 31, 12, 19, 8 dans un arbre initialement vide. Montrer à présent les
arbres rouge-noir issus de la suppression successive des clés 8, 12, 19, 31, 38, 41.
13.4.4 Dans quelles lignes du code de RN-S UPPRIMER -C ORRECTION pourrait-on examiner ou modifier la sentinelle nil[T] ?
13.4.5 Pour chacun des cas de la figure 13.7, donner la quantité de nœuds noirs entre la
racine du sous-arbre représenté et chacun des sous-arbres a, b, . . . , z, et vérifier que chaque
quantité reste la même après la transformation. Lorsqu’un nœud a l’attribut de couleur c ou
c , utiliser la notation compteur(c) ou compteur(c ) symboliquement dans votre décompte.
13.4.6 Les professeurs Picrate et Vinasse ont peur qu’au début du cas 1 de RN-S UPPRIMER C ORRECTION, le nœud p[x] puisse ne pas être noir. Si les professeurs pensent juste, alors les
lignes 5–6 sont erronées. Montrer que p[x] est forcément noir au début du cas 1, de sorte que
les professeurs se font du souci pour rien.
13.4.7 Supposons qu’un nœud x soit inséré dans un arbre rouge-noir via RN-I NSÉRER, puis
immédiatement supprimé via RN-S UPPRIMER. L’arbre rouge-noir résultant est-il le même
que l’arbre initial ? Justifier la réponse.
Problèmes
287
PROBLÈMES
c Dunod – La photocopie non autorisée est un délit
13.1. Ensembles dynamiques persistants
Au cours d’un algorithme, on a parfois besoin de conserver d’anciennes versions d’un
ensemble dynamique quand celui-ci est modifié. Un tel ensemble est dit persistant.
Une façon d’implémenter un ensemble persistant est de copier l’ensemble tout entier
chaque fois qu’il est modifié, mais cette approche peut ralentir un programme et
également consommer beaucoup d’espace. On peut parfois faire beaucoup mieux.
Considérons un ensemble persistant S avec les opérations I NSÉRER, S UPPRIMER
et R ECHERCHER, qu’on implémente à l’aide des arbres binaires de recherche représentés sur la figure 13.8(a). On conserve une nouvelle racine pour chaque version de
l’ensemble. Pour insérer la clé 5 dans l’ensemble, on crée un nouveau nœud de clé 5.
Ce nœud devient l’enfant gauche d’un nouveau nœud de clé 7, puisqu’on ne peut
pas modifier le nœud de clé 7 existant. De même, le nouveau nœud de clé 7 devient
l’enfant gauche d’un nouveau nœud de clé 8 dont l’enfant droit est l’ancien nœud de
clé 10. Le nouveau nœud de clé 8 devient, à son tour, l’enfant droit d’un nouvelle
racine r de clé 4 dont l’enfant gauche est l’ancien nœud de clé 3. On ne recopie ainsi
qu’une partie de l’arbre et l’on partage certains nœuds avec l’arbre initial, comme le
montre la figure 13.8(b).
On suppose que chaque nœud de l’arbre contient les champs clé, gauche et droite
mais aucun champ parent. (Voir aussi exercice 13.3.6.)
a. Pour un arbre binaire de recherche persistant général, identifier les nœuds qui ont
besoin d’être modifiés pour insérer une clé k ou supprimer un nœud y.
b. Écrire une procédure A RBRE -P ERSISTANT-I NSÉRER qui, étant donné un arbre
persistant T et une clé k à insérer, retourne un nouvel arbre persistant T qui résulte
de l’insertion de k dans T.
c. Si la hauteur de l’arbre binaire de recherche persistant T est h, quels sont les besoins en temps et en espace de votre implémentation de A RBRE -P ERSISTANTI NSÉRER ? (L’espace nécessaire est proportionnel au nombre de nouveaux nœuds
alloués.)
d. Supposons qu’on ait inclus le champ parent dans chaque nœud. Dans ce cas,
A RBRE -P ERSISTANT-I NSÉRER doit effectuer des recopies supplémentaires. Démontrer que les besoins en temps et en espace de A RBRE -P ERSISTANT-I NSÉRER
sont alors V(n), où n est le nombre de nœuds dans l’arbre.
e. Montrer comment utiliser des arbres rouge-noir pour garantir que le temps d’exécution et l’espace nécessaire, dans le cas le plus défavorable, sont O(lg n) par insertion ou suppression.
13 • Arbres rouge-noir
288
4
r
3
r
8
2
7
4
4
3
10
r′
8
2
7
7
8
10
5
(a)
(b)
Figure 13.8 (a) Un arbre binaire de recherche avec les clés 2, 3, 4, 7, 8, 10. (b) L’arbre binaire de
recherche persistant qui résulte de l’insertion de la clé 5. La version la plus récente de l’ensemble
est constituée des nœuds accessibles depuis la racine r et la précédente version est constituée
des nœuds accessibles depuis r. Les nœuds en gris foncé sont ajoutés lorsque la clé 5 est insérée.
13.2. Jointure d’arbres rouge-noir
L’opération de jointure prend deux ensembles dynamiques S1 et S2 et un élément x
tels que, pour tous x1 ∈ S1 et x2 ∈ S2 , on ait clé[x1 ] clé[x] clé[x2 ]. Elle retourne
un ensemble S = S1 ∪ {x} ∪ S2 . Dans ce problème, on s’intéresse à l’implémentation
de l’opération de jointure sur les arbres rouge-noir.
a. Étant donné un arbre rouge-noir T, on décide de conserver sa hauteur noire dans un
champ hn[T]. Montrer que ce champ peut être mis à jour par RN-I NSÉRER et RNS UPPRIMER sans que l’on ait besoin de gérer de l’espace en plus dans les nœuds
de l’arbre, ni d’augmenter les temps d’exécution asymptotiques. Montrer que, lors
d’une descente le long de T, on peut déterminer la hauteur noire de chaque nœud
visité en temps O(1) par nœud visité.
On souhaite implémenter l’opération RN-J OINTURE(T1 , x, T2 ), qui détruit T1 et T2
et retourne un arbre rouge-noir T = T1 ∪ {x} ∪ T2 . Soit n le nombre total de nœuds
dans T1 et T2 .
b. On suppose que hn[T1 ] hn[T2 ]. Décrire un algorithme à temps O(lg n) qui
trouve un nœud noir y dans T1 ayant une clé plus grande que tous les nœuds de
hauteur noire hn[T2 ].
c. Soit Ty le sous-arbre enraciné en y. Décrire comment remplacer Ty par Ty ∪{x}∪T2
en temps O(1) sans détruire la propriété d’arbre binaire de recherche.
d. Quelle couleur faut-il donner à x pour que les propriétés d’arbre rouge-noir 1, 3
et 5 soient conservées ? Décrire comment restaurer les propriétés 2 et 4 en temps
O(lg n).
Problèmes
289
e. Prouver que l’hypothèse faite en (b) ne nuit pas à la généralité. Décrire la situation
symétrique qui se produit quand hn[T1 ] hn[T2 ].
f. Montrer que le temps d’exécution de RN-J OINTURE est O(lg n).
c Dunod – La photocopie non autorisée est un délit
13.3. Arbres AVL
Un arbre AVL est un arbre binaire équilibré en hauteur : pour chaque nœud x, les
hauteurs des sous-arbres gauche et droite de x ne diffèrent que de 1 au plus. Pour
implémenter un arbre AVL, on gère un champ supplémentaire dans chaque nœud :
h[x] est la hauteur du nœud x. Comme pour tout autre arbre binaire de recherche T,
on suppose que racine[T] pointe vers le nœud racine.
a. Prouver qu’un arbre AVL à n nœuds a une hauteur O(lg n). (Conseil : Prouver
que, dans un arbre AVL de hauteur h, il y a au moins Fh nœuds, où Fh est le hème
nombre de Fibonacci.)
b. Pour faire une insertion dans un arbre AVL, on commence par placer un nœud à
l’endroit approprié dans l’ordre de l’arbre binaire. Après cette insertion, l’arbre
risque de ne plus être équilibré en hauteur. Plus précisément, les hauteurs des
enfants gauche et droite d’un certain nœud risquent de différer de 2. Décrire une
procédure E QUILIBRE(x) qui prend en entrée un sous-arbre enraciné en x dont les
enfants gauche et droite sont équilibrés en hauteur et ont des hauteurs qui diffèrent
d’au plus 2 (c’est-à-dire, |h[droite[x]] − h[gauche[x]]| 2), puis qui modifie le
sous-arbre enraciné en x pour qu’il soit équilibré en hauteur. (Conseil : Employer
des rotations.)
c. En utilisant la partie (b), décrire une procédure récursive AVL-I NSÉRER(x, z) qui
prend en entrée un nœud x d’un arbre AVL et un nœud nouvellement créé z (dont la
clé a déjà été initialisée), puis qui ajoute z au sous-arbre enraciné en x en préservant
la propriété selon laquelle x est la racine d’un arbre AVL. Comme dans A RBRE I NSÉRER de la section 12.3, on supposera que clé[z] a déjà été remplie et que
gauche[z] = NIL et droite[z] = NIL ; on supposera également que h[z] = 0. Donc,
pour insérer le nœud z dans l’arbre AVL T, on appelle AVL-I NSÉRER(racine[T], z).
d. Donner un exemple d’arbre AVL à n nœuds dans lequel une opération AVLI NSÉRER entraîne l’exécution de V(lg n) rotations.
13.4. Arbretas
Quand on insère un ensemble de n éléments dans un arbre binaire de recherche,
l’arbre résultant risque d’être abominablement déséquilibré et d’entraîner des temps
de recherche très longs. Cependant, comme nous l’avons vu à la section 12.4, les
arbres binaires construits aléatoirement ont tendance à être équilibrés. Par conséquent, une stratégie qui, en moyenne, construit un arbre équilibré pour un ensemble
fixé d’éléments consiste à permuter aléatoirement les éléments, puis à les insérer dans
cet ordre dans l’arbre.
13 • Arbres rouge-noir
290
Mais si l’on n’a pas tous les éléments en même temps ? Si on reçoit les éléments
un par un, est-ce que l’on peut encore construire un arbre binaire à partir de ces
éléments ?
Nous allons examiner une structure de données qui répond à ce problème. Un
arbretas est un arbre binaire de recherche qui a une façon modifiée d’ordonner les
nœuds. La figure 13.9 montre un exemple. Comme d’habitude, chaque nœud x de
l’arbre a une valeur de clé clé[x]. En outre, on affecte priorité[x] qui est un nombre
aléatoire choisi de manière indépendante pour chaque nœud. On suppose que toutes
les priorités sont distinctes et aussi que toutes les clés sont distinctes. Les nœuds de
l’arbretas sont ordonnés de telle façon que les clés respectent la propriété d’arbre
binaire de recherche et que les priorités respectent la propriété d’ordre de tas min :
– Si v est un enfant gauche de u, alors clé[v] < clé[u].
– Si v est un enfant droite de u, alors clé[v] > clé[u].
– Si v est un enfant de u, alors priorité[v] > priorité[u].
(La combinaison de ces propriétés explique le nom « arbretas » : c’est un mélange
d’arbre (tree) et de tas (heap).)
G: 4
B: 7
A: 10
H: 5
E: 23
K: 65
I: 73
Figure 13.9 Un arbretas. Chaque nœud x est étiqueté par clé[x] : priorité[x]. Par exemple, la
racine a la clé G et la priorité 4.
Voici une façon utile de se représenter les arbretas. Supposons que l’on insère
les nœuds x1 , x2 , . . . , xn , avec les clés associées, dans un arbretas. Alors, l’arbretas
résultant est l’arbre qui aurait été formé si les nœuds avaient été insérés dans un arbre
binaire classique dans l’ordre donné par leurs priorités (choisies au hasard) ; c’est-àdire, priorité[xi ] < priorité[xj ] signifie que xi a été inséré avant xj .
a. Montrer que, étant donné un ensemble de nœuds x1 , x2 , . . . , xn avec les clés et
priorités associées (toutes distinctes), il existe un unique arbretas qui soit associé
à ces nœuds.
b. Montrer que la hauteur attendue d’un arbretas est Q(lg n), et donc que le temps de
recherche d’une valeur dans l’arbretas est Q(lg n).
Voyons comment insérer un nœud dans un arbretas existant. La première chose que
nous faisons, c’est d’assigner au nouveau nœud une priorité aléatoire. On appelle ensuite l’algorithme d’insertion, baptisé A RBRETAS -I NSÉRER, dont le fonctionnement
est illustré par la figure 13.10.
Problèmes
291
G: 4
B: 7
A: 10
G: 4
H: 5
E: 23
C: 25
K: 65
B: 7
A: 10
I: 73
D: 9
C: 25
G: 4
G: 4
H: 5
E: 23
B: 7
K: 65
A: 10
I: 73
E: 23
K: 65
I: 73
C: 25
(c)
(d)
G: 4
F: 2
B: 7
H: 5
D: 9
C: 25
H: 5
D: 9
D: 9
K: 65
I: 73
(b)
C: 25
A: 10
E: 23
(a)
B: 7
A: 10
H: 5
F: 2
K: 65
E: 23
I: 73
…
A: 10
G: 4
B: 7
H: 5
D: 9
C: 25
E: 23
K: 65
I: 73
c Dunod – La photocopie non autorisée est un délit
(e)
(f)
Figure 13.10 Fonctionnement de A RBRETAS -I NSÉRER. (a) L’arbretas original, avant l’insertion.
(b) L’arbretas après insertion d’un nœud de clé C et de priorité 25. (c)–(d) Étapes intermédiaires
de l’insertion d’un nœud de clé D et de priorité 9. (e) L’arbretas après les insertions faites aux
parties (c) et (d). (f) L’arbretas après insertion d’un nœud de clé F et de priorité 2.
c. Expliquer le fonctionnement de A RBRETAS -I NSÉRER. Expliquer le concept en
langage naturel, puis donner le pseudo code. (Conseil : On exécute la procédure
classique d’insertion dans un arbre binaire de recherche, puis on fait des rotations
pour restaurer la propriété d’ordre de tas min.)
d. Montrer que le temps d’exécution attendu de A RBRETAS -I NSÉRER est Q(lg n).
13 • Arbres rouge-noir
292
A RBRETAS -I NSÉRER effectue une recherche, puis une suite de rotations. Bien que
ces deux opérations aient le même temps d’exécution attendu, elles ont des coûts différents en pratique. Une recherche lit des données dans l’arbretas sans le modifier.
En comparaison, une rotation modifie des pointeurs vers parent et vers enfant dans
l’arbretas. Sur la plupart des ordinateurs, les lectures sont plus rapides que les écritures. On voudrait donc que A RBRETAS -I NSÉRER fasse peu de rotations. Nous allons
montrer que le nombre attendu de rotations effectuées est borné par une constante.
Pour ce faire, on a besoin de quelques définitions, qui sont illustrées sur la figure 13.11. La dorsale gauche d’un arbre binaire de recherche T est le chemin reliant la racine au nœud qui a la clé minimale. Autrement dit, la dorsale gauche est
le chemin partant de la racine et composé uniquement de bords gauches. De manière
symétrique, la dorsale droite de T est le chemin partant de la racine et composé uniquement de bords droits. La longueur d’une dorsale est le nombre de nœuds qu’elle
contient.
15
9
3
15
18
9
12
25
6
21
(a)
3
18
12
25
6
21
(b)
Figure 13.11 Dorsales d’un arbre binaire de recherche. La dorsale gauche est dessinée sur fond
ombré en (a), et la dorsale droite est dessinée sur fond ombré en (b).
e. On considère l’arbretas T juste après l’insertion de x via A RBRETAS -I NSÉRER.
Soit C la longueur de la dorsale droite du sous-arbre gauche de x. Soit D la longueur de la dorsale gauche du sous-arbre droite de x. Prouver que le nombre total
de rotations effectuées lors de l’insertion de x est égal à C + D.
On va maintenant calculer les valeurs attendues de C et D. Sans nuire à la généralité,
on suppose que les clés sont 1, 2, . . . , n, vu que l’on ne fait que les comparer entre
elles.
Pour les nœuds x et y, où y fi x, soient k = clé[x] et i = clé[y], et définissons les
variables indicatrices
Xi,k = I {y est dans la dorsale droite du sous-arbre gauche de x (dans T)} .
f. Montrer que Xi,k = 1 si et seulement si priorité[y] > priorité[x], clé[y] < clé[x]
et, pour tout z tel que clé[y] < clé[z] < clé[x], on a priorité[y] < priorité[z].
Notes
293
g. Montrer que
Pr {Xi,k = 1} =
1
(k − i − 1)!
=
.
(k − i + 1)! (k − i + 1)(k − i)
h. Montrer que
E [C] =
k−1
j=1
1
1
=1− .
j(j + 1)
k
i. Utiliser un argument de symétrie pour montrer que
1
E [D] = 1 −
.
n−k+1
j. En conclure que le nombre attendu de rotations effectuées lors de l’insertion d’un
nœud dans un arbretas est inférieur à 2.
c Dunod – La photocopie non autorisée est un délit
NOTES
Le principe d’équilibrage d’un arbre de recherche est dû à Adel’son-Vel’skiı̆ et Landis [2],
qui introduisirent en 1962 une classe d’arbres équilibrés appelée « arbres AVL » (décrite au
problème 13-3). Une autre classe d’arbres de recherche, dite « arbres 2-3 », fut introduite par
J. E. Hopcroft (non publié) en 1970. L’équilibre est maintenu via manipulation des degrés
des nœuds dans l’arbre. Une généralisation des arbres 2-3, présentée par Bayer et McCreight
[32] et appelée B-arbres, forme le sujet du chapitre 18.
Les arbres rouge-noir ont été inventés par Bayer [31] sous le nom « B-arbres binaires
symétriques ». Guibas et Sedgewick [135] ont étudié leur propriétés en détail et introduit la
convention du coloriage rouge-noir. Andersson [15] donne une variante à code plus simple
des arbres rouge-noir. Weiss [311] appelle cette variante arbres AA. Un arbre AA ressemble
à un arbre rouge-noir, sauf que les enfants de gauche ne peuvent jamais être rouges.
Les arbretas ont été proposés par Seidel et Aragon [271]. C’est l’implémentation par défaut
employée pour un dictionnaire dans LEDA, collection bien implémentée de structures de
données et d’algorithmes.
Il existe une foule d’autres variantes pour arbres binaires équilibrés, dont : arbres équilibrés
en poids [230], arbres k-voisin [213] et arbres bouc-émissaire [108].
La variante la plus étonnante est peut-être celle des « arbres déployés », introduits par
Sleator et Tarjan [281], qui sont des arbres « auto-adaptatifs ». (Tarjan [292] fournit une
bonne description des arbres déployés). Ces arbres maintiennent leur équilibre sans aucune
condition explicite d’équilibrage, comme par exemple la couleur. À la place, des « opérations
de déploiement » (mettant en jeu des rotations) sont faites dans l’arbre chaque fois qu’on y
accède. Le coût amorti (voir chapitre 17) de chaque opération sur un arbre à n nœuds est
O(lg n).
Les listes à saut (skip list) [251] sont une solution de remplacement pour les arbres binaires
équilibrés. Une liste à saut est une liste chaînée, dotée d’un certain nombre de pointeurs
supplémentaires. Chaque opération de dictionnaire se fait avec un temps moyen de O(lg n)
sur une liste à saut de n éléments.
Chapitre 14
Extension d’une structure
de données
c Dunod – La photocopie non autorisée est un délit
Pour faire face à certaines situations pratiques, il suffit parfois d’une structure de données classique, par exemple une liste doublement chaînée, une table de hachage ou un
arbre binaire de recherche ; mais d’autres contextes demandent un peu d’imagination.
Cependant, les cas où l’on doit créer de toutes pièces un nouveau type de structure
de données sont rares. Le plus souvent, il suffit d’étendre une structure classique en y
stockant des informations supplémentaires. On peut alors programmer de nouvelles
opérations qui permettent à la structure de données de supporter l’application voulue. Toutefois, étendre une structure de données n’est pas toujours évident, car les
informations supplémentaires doivent être mises à jour et prises en compte par les
opérations ordinaires sur la structure de données.
Ce chapitre étudie deux structures de données qui sont construites en étendant
des arbres rouge-noir. La section 14.1 décrit une structure de données qui supporte
les opérations générales sur les rangs dans un ensemble dynamique. On peut alors
trouver rapidement le ième plus petit nombre d’un ensemble ou le rang d’un élément
donné dans un ensemble totalement ordonné. La section 14.2 modélise le processus
d’extension d’une structure de données et fournit un théorème qui peut simplifier
l’extension des arbres rouge-noir. Dans la section 14.3, on s’aidera de ce théorème
pour concevoir une structure de données capable de gérer un ensemble dynamique
d’intervalles, par exemple des intervalles de temps. Étant donné un intervalle entre
deux requêtes, on peut alors rapidement trouver dans l’ensemble un intervalle avec
lequel il se superpose.
14 • Extension d’une structure de données
296
14.1 RANGS DYNAMIQUES
Le chapitre 9 a introduit la notion de rang. Plus précisément, le ième rang d’un ensemble à n éléments, où i ∈ {1, 2, . . . , n}, est tout simplement l’élément de l’ensemble ayant la ième plus petite clé. Nous avons vu qu’on pouvait retrouver un rang
quelconque en O(n) dans un ensemble non trié. Dans cette section, on verra comment les arbres rouge- noir peuvent être modifiés pour qu’on puisse retrouver un
rang quelconque en O(lg n). Nous verrons également comment déterminer le rang
d’un élément, à savoir sa position dans l’ordre linéaire de l’ensemble dans un temps
O(lg n).
La figure 14.1 montre une structure de données qui permet d’exécuter rapidement
les opérations sur les rangs. Un arbre de rangs T est tout simplement un arbre rougenoir avec des informations supplémentaires conservées à chaque nœud. En plus des
champs habituels utilisés dans un arbre rouge-noir, clé[x], couleur[x], p[x], gauche[x],
et droite[x], tout nœud x contient aussi un champ taille[x]. Ce champ contient le
nombre de nœuds (internes) du sous-arbre enraciné en x (x compris), autrement dit la
taille du sous-arbre. Si l’on suppose que la taille de la sentinelle est 0, c’est-à-dire si
l’on affecte taille[NIL] = 0, alors on a l’identité
taille[x] = taille[gauche[x]] + taille[droite[x]] + 1 .
Nous n’imposons pas aux clés d’un arbre de rangs qu’elles soient distinctes. (Par
exemple, l’arbre de la figure 14.1 a deux clés de valeur 14 et deux clés de valeur 21.)
En présence de clés identiques, la notion précédente de rang n’est pas bien définie. On
supprime cette ambiguïté, pour un arbre de rangs, en définissant le rang d’un élément
comme étant la position à laquelle il serait affiché dans le cadre d’un parcours infixe
de l’arbre. Sur la figure 14.1, par exemple, la clé 14 stockée dans un nœud noir a le
rang 5, et la clé 14 stockée dans un nœud rouge a le rang 6.
26
20
17
41
12
21
30
7
4
5
10
4
3
7
14
16
19
2
2
7
12
14
20
2
1
1
1
21
28
1
1
clé
taille
47
1
38
3
35
39
1
1
1
Figure 14.1 Un arbre de rangs, qui est un arbre rouge-noir étendu. Les nœuds rouges sont en
gris, les nœuds noirs sont en noir. En plus des champs habituels, chaque nœud x comporte un
champ taille[x], qui représente le nombre de nœuds du sous-arbre enraciné en x.
14.1
Rangs dynamiques
297
a) Retrouver un élément d’un rang donné
Avant de montrer comment on peut maintenir cette nouvelle information à jour pendant l’insertion et la suppression, examinons l’implémentation de deux requêtes sur
les rangs qui utilisent cette information supplémentaire. On commence par une opération qui récupère un élément d’un rang donné. La procédure R ÉCUPÉRER -R ANG(x, i)
retourne un pointeur sur le nœud contenant la ième plus petite clé du sous-arbre enraciné en x. Pour trouver la ième plus petite clé dans un arbre de rangs T, on appelle
R ÉCUPÉRER -R ANG(racine[T], i).
Le principe de R ÉCUPÉRER -R ANG ressemble à celui des algorithmes de sélection
du chapitre 9. La valeur de taille[gauche[x]] est le nombre de nœuds qui précèdent x
dans un parcours infixe du sous-arbre enraciné en x. Donc taille[gauche[x]] + 1 est le
rang de x dans le sous-arbre enraciné en x.
c Dunod – La photocopie non autorisée est un délit
R ÉCUPÉRER -R ANG(x, i)
1 r ← taille[gauche[x]]+1
2 si i = r
3
alors retourner x
4 sinon si i < r
5
alors retourner R ÉCUPÉRER -R ANG(gauche[x], i)
6 sinon retourner R ÉCUPÉRER -R ANG(droite[x], i − r)
A la ligne 1 de R ÉCUPÉRER -R ANG, on calcule r, rang du nœud x à l’intérieur du
sous-arbre enraciné en x. Si i = r, alors le nœud x est le ième plus petit élément, et
on retourne x à la ligne 3. Si i < r, alors le ième plus petit élément se trouve dans le
sous-arbre gauche de x, et l’on commence une récursivité dans gauche[x] à la ligne 5.
Si i > r, le ième plus petit élément se trouve dans le sous-arbre droit de x. Comme il
y a r éléments dans le sous-arbre enraciné en x qui précèdent le sous-arbre droit de
x lors du parcours infixe, le ième plus petit élément du sous-arbre enraciné en x est
le (i − r)ème plus petit élément du sous-arbre enraciné en droite[x]. Cet élément est
déterminé récursivement à la ligne 6.
Pour comprendre le comportement de R ÉCUPÉRER -R ANG, considérons la recherche du 17ème plus petit élément de l’arbre de rangs de la figure 14.1. On
commence avec x à la racine, donc la clé est 26, et avec i = 17. Comme la taille du
sous-arbre gauche de 26 est 12, son rang est 13. On sait donc que le nœud de rang 17
est le 17 − 13 = 4ème plus petit élément dans le sous-arbre droit de 26. Après l’appel
récursif, x est le nœud de clé 41, et i = 4. Comme la taille du sous-arbre gauche de
41 est 5, son rang à l’intérieur du sous-arbre est 6. On sait donc que le nœud de rang
4 est le 4ème plus petit élément du sous-arbre gauche de 41. Après l’appel récursif,
x pointe sur le nœud de clé 30, et son rang à l’intérieur de son sous-arbre est 2. On
continue donc la récursivité pour trouver le 4 − 2 = 2ème plus petit élément du
sous-arbre dont la racine est le nœud de clé 38. On voit à présent que son sous-arbre
gauche à une taille 1, ce qui signifie que c’est le deuxième plus petit élément. Un
pointeur sur le nœud de clé 38 est alors retourné par la procédure.
298
14 • Extension d’une structure de données
Comme chaque appel récursif descend d’un niveau dans l’arbre de rangs, le temps
total de R ÉCUPÉRER -R ANG est au pire proportionnel à la hauteur de l’arbre. Comme
c’est un arbre rouge-noir, sa hauteur est O(lg n), où n est le nombre de nœuds. Le
temps d’exécution de R ÉCUPÉRER -R ANG est donc O(lg n) pour un ensemble dynamique à n éléments.
b) Déterminer le rang d’un élément
Étant donné un pointeur vers un nœud x d’un arbre de rangs T, la procédure
D ÉTERMINER -R ANG retourne la position de x dans l’ordre linéaire tel que déterminé dans un parcours infixe de T.
D ÉTERMINER -R ANG(T, x)
1 r ← taille[gauche[x]] + 1
2 y←x
3 tant que y fi racine[T]
4
faire si y = droite[p[y]]
5
alors r ← r + taille[gauche[p[y]]] + 1
6
y ← p[y]
7 retourner r
La procédure fonctionne de la manière suivante. Le rang de x peut être
vu comme étant le nombre de nœuds qui précèdent x dans un parcours infixe de l’arbre, nombre augmenté de 1 pour tenir compte de x lui-même.
D ÉTERMINER -R ANG conserve l’invariant de boucle que voici : Au début de
chaque itération de la boucle tant que des lignes 3–6, r est le rang de clé[x]
dans le sous-arbre issu du nœud y.
On va utiliser cet invariant pour montrer que D ÉTERMINER -R ANG fonctionne correctement :
Initialisation : Avant la première itération, la ligne 1 fait de r le rang de clé[x] dans
le sous-arbre issu de x. L’affectation y ← x en ligne 2 rend l’invariant vrai lors de
la première exécution du test de la ligne 3.
Maintenance : à la fin de chaque itération de la boucle tant que, on fait y ← p[y].
Il faut donc montrer que, si r est le rang de clé[x] dans le sous-arbre issu de y au
début du corps de la boucle, alors r est le rang de clé[x] dans le sous-arbre issu
de p[y] à la fin du corps de la boucle. À chaque itération de la boucle tant que,
on considère le sous-arbre issu de p[y]. On a déjà compté le nombre de nœuds
du sous-arbre issu de y qui précèdent x dans un parcours infixe, de sorte qu’il faut
ajouter les nœuds du sous-arbre issu du frère de y qui précèdent x dans un parcours
infixe, plus 1 pour p[y] s’il précède, lui aussi, x. Si y est un enfant gauche, alors
ni p[y] ni aucun nœud du sous-arbre droit de p[y] ne précède x, de sorte que l’on
ne modifie pas r. Sinon, y est un enfant droit et tous les nœuds du sous-arbre
gauche de p[y] précèdent x, ce que fait p[y] lui-même. Donc, en ligne 5, on ajoute
taille[gauche[p[y]]] + 1 à la valeur courante de r.
14.1
Rangs dynamiques
299
Terminaison : La boucle se termine quand y = racine[T], de sorte que le sous-arbre
issu de y n’est autre que l’arbre complet. Par conséquent, la valeur de r est le rang
de clé[x] dans l’arbre tout entier.
Comme exemple, lorsqu’on exécute D ÉTERMINER -R ANG sur l’arbre de rangs de
la figure 14.1 pour trouver le rang du nœud de clé 38, on obtient la séquence suivante
de valeurs pour clé[y] et r au sommet de la boucle tant que :
itération clé[y] r
1
38
2
2
30
4
3
41
4
4
26
17
La procédure retourne le rang 17.
Comme chaque itération de la boucle tant que prend O(1), et que y remonte d’un
niveau dans l’arbre à chaque itération, le temps d’exécution de D ÉTERMINER -R ANG
est au pire proportionnel à la hauteur de l’arbre : O(lg n) sur un arbre de rangs à n
nœuds.
c) Conserver la taille des sous-arbres
c Dunod – La photocopie non autorisée est un délit
Connaissant le champ taille de chaque nœud, R ÉCUPÉRER -R ANG et D ÉTERMINER R ANG peuvent rapidement calculer les informations concernant les rangs. Mais si ces
champs ne peuvent pas être pris en compte efficacement par les opérations de modification élémentaires des arbres rouge-noir, notre travail n’aura servi à rien. Nous
allons montrer que les tailles des sous-arbres peuvent être gérées aussi bien lors de
l’insertion que lors de la suppression, sans affecter le temps d’exécution asymptotique
de chaque opération.
Nous avions remarqué à la section 13.3 que l’insertion dans un arbre rouge-noir
était constitués de deux opérations distinctes. La première phase descendait le long
de l’arbre à partir de la racine, pour insérer le nouveau nœud comme fils d’un nœud
existant. La seconde phase remontait le long l’arbre en changeant les couleurs, et
terminait en effectuant des rotations permettant de préserver les propriétés des arbres
rouge-noir.
Pour gérer la taille des sous-arbres pendant la première phase, on se contente
d’incrémenter taille[x] pour chaque nœud x du chemin reliant la racine aux feuilles.
Le nouveau nœud se voit affecter une taille égale à 1. Comme le chemin parcouru
contient O(lg n) nœuds, le coût supplémentaire requis par la gestion des champs taille
est O(lg n).
Lors de la seconde phase, les seuls changements structurels affectant l’arbre rougenoir sont causés par les rotations, qui sont au plus au nombre de deux. Par ailleurs,
une rotation est une opération locale : elle ne remet en question que les champs
taille des deux nœuds de chaque côté du lien autour duquel la rotation s’effectue. En
14 • Extension d’une structure de données
300
partant du code de ROTATION -G AUCHE(T, x) à la section 13.2, on ajoute les lignes
suivantes :
13 taille[y] ← taille[x]
14 taille[x] ← taille[gauche[x]] + taille[droite[x]] + 1
La figure 14.2 illustre la mise à jour des champs. La modification apportée à
ROTATION -D ROITE est symétrique.
42
19
ROTATION-GAUCHE (T, x)
93
x
19
93
12
42
y
6
4
11
ROTATION-DROITE (T, y)
7
y
x
7
6
4
Figure 14.2 Mise à jour de la taille des sous-arbres lors des rotations. Les champs taille qui
doivent être mis à jour sont ceux des nœuds situés de part et d’autre du lien autour duquel
s’effectue la rotation. Les mises à jour sont locales, et ne demandent que les informations taille
conservées dans x, y et dans les racines des sous-arbres représentés par des triangles.
Comme au plus deux rotations sont effectuées pendant l’insertion dans un arbre
rouge-noir, on ne dépense qu’un temps supplémentaire en O(1) pour mettre à jour
les champs taille lors de la seconde phase. Le temps total pris par l’insertion dans un
arbre de rangs à n nœuds est donc O(lg n), ce qui est asymptotiquement identique à
celui d’un arbre rouge-noir ordinaire.
La suppression dans un arbre rouge-noir est également constituée de deux phases :
la première agit sur l’arbre de recherche sous-jacent, et la seconde génère au plus trois
rotations, sans provoquer d’autres modifications structurelles. (Voir section 13.4.) La
première phase détache un nœud y. Pour mettre à jour la taille des sous-arbres, on se
contente de longer un chemin remontant depuis le nœud y vers la racine, en décrémentant le champ taille de chaque nœud situé sur le chemin. Comme ce chemin a la
longueur O(lg n) dans un arbre rouge-noir à n nœuds, le temps supplémentaire requis
pour maintenir les champs taille lors de la première phase est O(lg n). Les rotations
en O(1) de la seconde phase de la suppression peuvent être gérées de la même façon
que pour l’insertion. Par conséquent, l’insertion comme la suppression, maintenance
des champs taille comprise, prennent un temps O(lg n) pour un arbre de rangs à n
nœuds.
Exercices
14.1.1 Montrer comment R ÉCUPÉRER -R ANG (T, 10) agit sur l’arbre rouge-noir T de la
figure 14.1.
14.2
Comment étendre une structure de données
301
14.1.2 Montrer l’action de D ÉTERMINER -R ANG(T, x) sur l’arbre rouge-noir T de la figure 14.1 et le nœud x pour lequel clé[x] = 35.
14.1.3 Écrire une version non récursive de R ÉCUPÉRER -R ANG.
14.1.4 Écrire une procédure récursive R ECHERCHER -R ANG -C LÉ(T, k) qui prend en entrée
un arbre de rangs T et une clé k, et qui retourne le rang de k dans l’ensemble dynamique
représenté par T. On supposera que les clés de T sont distinctes.
14.1.5 Étant donné un élément x dans un arbre de rangs à n nœuds, et un entier naturel i,
comment peut-on déterminer le ième successeur de x dans l’ordre linéaire de l’arbre avec un
temps O(lg n) ?
14.1.6 Observons que, chaque fois que le champ taille est utilisé dans R ÉCUPÉRER -R ANG
ou D ÉTERMINER -R ANG, il ne sert qu’à calculer le rang du nœud dans le sous-arbre issu de
ce nœud. Supposons donc que l’on décide de stocker dans chaque nœud son rang dans le
sous-arbre dont il est la racine. Montrer comment gérer cette donnée lors de l’insertion et de
la suppression. (Ne pas oublier que ces deux opérations peuvent provoquer des rotations).
14.1.7 Montrer comment utiliser un arbre de rangs pour compter en temps O(n lg n) le
nombre d’inversions (voir problème 2.4) dans un tableau de taille n.
14.1.8 Considérons n cordes dans un cercle, chacune étant définie par ses extrémités. Décrire un algorithme en O(n lg n) permettant de déterminer le nombre de paires de cordes qui
se croisent à l’intérieur du cercle. (Par exemple,
! " si les n cordes sont toutes des diamètres qui
se croisent au centre, la bonne réponse sera n2 .) On supposera que deux cordes quelconques
n’ont jamais d’extrémité commune.
c Dunod – La photocopie non autorisée est un délit
14.2 COMMENT ÉTENDRE UNE STRUCTURE DE DONNÉES
En phase de conception d’un algorithme, on a très fréquemment recours à l’extension
d’une structure de données classique pour supporter des fonctionnalités supplémentaires. Nous y ferons à nouveau appel dans la prochaine section pour concevoir une
structure de données permettant de gérer des intervalles. Dans cette section, nous
examinerons les étapes qui président à cette extension. Nous démontrerons également
un théorème nous permettant dans de nombreux cas d’étendre facilement des arbres
rouge-noir.
L’extension d’une structure de données peut être divisée en quatre étapes :
1) choisir une structure de données sous-jacente,
2) déterminer les informations supplémentaires à gérer dans la structure sous-jacente.
3) vérifier que les informations supplémentaires seront compatibles avec les opérations habituelles de modification de la structure de données sous-jacente, et
4) créer de nouvelles opérations.
302
14 • Extension d’une structure de données
Comme avec toute méthode de conception normative, vous ne devrez pas suivre aveuglément ces étapes dans l’ordre. Tout travail de conception contient le plus souvent
une partie d’essais et d’erreurs, et se déroule habituellement en parallèle sur toutes les
étapes. Il ne sert à rien, par exemple, de déterminer les informations supplémentaires
et de développer les nouvelles opérations (étapes 2 et 4) si l’on est incapable de gérer
efficacement les informations supplémentaires. Néanmoins, ce plan en quatre étapes
balise bien vos efforts d’extension d’une structure de données, et c’est également un
bon moyen d’organiser la documentation d’une structure de données étendue.
Nous avons suivi ces quatre étapes dans la section 14.1 pour établir nos arbres de
rangs. Pour l’étape 1, nous avons choisi des arbres rouge-noir comme structure de
données principale. Un indice de la pertinence des arbres rouge-noir est donné par
leur support efficace d’autres opérations d’ordre total sur des ensembles dynamiques,
comme M INIMUM, M AXIMUM, S UCCESSEUR et P RÉDÉCESSEUR.
Pour l’étape 2, nous avons fourni le champ taille, qui stocke dans chaque nœud x la
taille du sous-arbre de racine x. En général, les informations supplémentaires rendent
les opérations plus efficaces. Par exemple, on aurait pu implémenter R ÉCUPÉRER R ANG et D ÉTERMINER -R ANG en se servant uniquement des clés conservées dans
l’arbre, mais elles ne se seraient pas exécutées en O(lg n). Parfois, l’information additionnelle est un pointeur et non une donnée, comme dans l’exercice 14.2.1.
Dans l’étape 3, nous nous sommes assurés que l’insertion et la suppression pouvaient gérer les champs taille tout en continuant à s’exécuter en O(lg n). Idéalement,
un petit nombre de modifications de la structure de données devrait suffire à gérer les
informations supplémentaires. Par exemple, si l’on s’était contenté de stocker dans
chaque nœud son rang dans l’arbre, les procédures R ÉCUPÉRER -R ANG et D ÉTER MINER -R ANG s’exécuteraient rapidement, mais l’insertion d’un nouvel élément minimal provoquerait le changement de cette donnée dans tous les nœuds de l’arbre. En
revanche, quand ce sont les tailles de sous-arbre qui sont stockées, l’insertion d’un
nouvel élément provoque des changements dans seulement O(lg n) nœuds.
Pour l’étape 4, nous avons développé les opérations R ÉCUPÉRER -R ANG et
D ÉTERMINER -R ANG. Après tout, c’est le besoin de disposer de nouvelles opérations qui nous a fait étendre la structure de données. Parfois, au lieu de développer
de nouvelles opérations, on se sert de l’information supplémentaire pour rendre plus
efficaces des opérations déjà existantes, comme dans l’exercice 14.2.1.
a) Extension des arbres rouge-noir
Lorsque des arbres rouge-noir forment le squelette d’une structure étendue, on
peut démontrer que certains types d’informations supplémentaires peuvent toujours
être gérés efficacement par l’insertion et la suppression, ce qui facilite grandement
l’étape 3. La démonstration du théorème suivant est similaire à la démonstration qui,
à la section 14.1, prouve que le champ taille peut être géré pour des arbres de rangs.
14.2
Comment étendre une structure de données
303
Soit c un champ qui étend la
structure d’un arbre rouge-noir T à n nœuds ; on suppose que le contenu de c pour
un nœud x peut être calculé seulement à l’aide des informations présentes dans les
nœuds x, gauche[x] et droite[x], y compris c[gauche[x]] et c[droite[x]]. Alors, il est
possible de gérer les valeurs de c dans tous les nœuds de T lors d’une insertion ou
d’une suppression sans affecter asymptotiquement le temps O(lg n) de ces opérations.
Théorème 14.1 (Extension d’un arbre rouge-noir)
Démonstration : L’idée générale de la démonstration est qu’un changement dans
c Dunod – La photocopie non autorisée est un délit
le champ c d’un nœud x ne se propage qu’aux ancêtres de x dans l’arbre. Autrement
dit, modifier c[x] peut obliger à mettre à jour c[p[x]], Mais rien d’autre ; mettre à
jour c[p[x]] peut obliger à modifier c[p[p[x]]], mais rien d’autre ; et ainsi de suite,
jusqu’à la racine. Lorsque c[racine[T]] est mis à jour, aucun autre nœud ne dépend
de la nouvelle valeur, et donc le processus se termine. Comme la hauteur d’un arbre
rouge-noir est O(lg n), modifier le champ c d’un nœud a un coût O(lg n) nécessaire à
la mise à jour des nœuds qui dépendent de cette modification.
L’insertion d’un nœud x dans T est constituée de deux phases. (Voir la section 13.3.)
Pendant la première phase, x est inséré comme fils d’un nœud p[x] déjà présent. La
valeur de c[x] peut être calculée en un temps O(1) puisque, par hypothèse, elle ne
dépend que des informations présentes dans les autres champs de x et des données
contenues dans les fils de x ; mais les enfants de x sont tous deux la sentinelle nil[T].
Une fois que c[x] est calculé, la modification remonte dans l’arbre. Le temps total de
la première phase d’insertion est donc O(lg n). Pendant la deuxième phase, les seuls
changements structurels apportés à l’arbre viennent des rotations. Comme deux nœuds
seulement changent lors d’une rotation, le temps global de mise à jour des champs c
est O(lg n) par rotation. Comme le nombre de rotations pendant l’insertion est au plus
égal à deux, le temps total de l’insertion est O(lg n).
Comme l’insertion, la suppression comprend deux phases. (Voir la section 13.4.) Lors
de la première phase, des modifications surviennent si le nœud supprimé est remplacé
par son successeur, et quand le nœud supprimé ou son successeur est détaché. La propagation des mises à jours de c provoquées par ces modifications coûte au plus O(lg n),
puisque les changement modifient l’arbre localement. La correction d’un arbre rougenoir pendant la seconde phase fait appel à trois rotations au plus, et chaque rotation
requiert au plus un temps O(lg n) pour propager les mises à jour sur c. Ainsi, comme
pour l’insertion, le temps total de la suppression est O(lg n).
❑
Dans de nombreux cas, par exemple pour la gestion des champs taille dans les
arbres de rangs, le coût d’une mise à jour après une rotation est O(1), et non le
O(lg n) trouvé dans la démonstration du théorème 14.1. Un exemple en est donné
à l’exercice 14.2.4.
Exercices
14.2.1 Montrer comment mettre en œuvre les requêtes d’ensemble dynamique M INIMUM,
M AXIMUM, S UCCESSEUR et P RÉDÉCESSEUR en temps O(1), dans le cas le plus défavorable,
sur un arbre de rangs étendu. Les performances asymptotiques des autres opérations sur les
arbres de rangs ne devront pas être affectées. (Conseil : Ajouter des pointeurs aux nœuds.)
304
14 • Extension d’une structure de données
14.2.2 Les hauteurs noires des nœuds d’un arbre rouge-noir peuvent-elles être gérées en tant
que champs des nœuds de l’arbre sans que les performances asymptotiques des opérations
d’arbre rouge-noir soient affectées ? Justifier la réponse.
14.2.3 Peut-on utiliser un champ supplémentaire dans les nœuds d’un arbre rouge-noir pour
gérer efficacement la profondeur des nœuds. Dire pourquoi.
14.2.4 Soit ⊗ un opérateur binaire associatif, et soit a un champ géré dans chaque nœud
d’un arbre rouge-noir. Supposons qu’on veuille inclure dans chaque nœud x un champ supplémentaire c tel que c[x] = a[x1 ] ⊗ a[x2 ] ⊗ · · · ⊗ a[xm ], où x1 , x2 , . . . , xm est la liste infixe
des nœuds du sous-arbre enraciné en x. Montrer que les champs c peuvent être correctement
mis à jour en O(1) après une rotation. Modifier légèrement votre démonstration pour montrer
que les champs taille des arbres de rangs peuvent être gérés en temps O(1) par rotation.
14.2.5 On souhaite étendre les arbres rouge-noir avec une opération RN-E NUMÉRER(x,
a, b) qui affiche toutes les clés k telles que a k b d’un arbre rouge-noir enraciné en x.
Décrire comment RN-E NUMÉRER peut être implémentée en temps Q(m + lg n), où m est le
nombre de clés qui sont affichées et n le nombre de nœuds internes de l’arbre. (Conseil : il
est inutile d’ajouter de nouveaux champs à l’arbre rouge-noir.)
14.3 ARBRES D’INTERVALLES
Dans cette section, on étendra des arbres rouge-noir pour supporter les opérations sur
des ensembles dynamiques d’intervalles. Un intervalle fermé est une paire ordonnée de nombres réels [t1 , t2 ], avec t1 t2 . L’intervalle [t1 , t2 ] représente l’ensemble
{t ∈ R : t1 t t2 }. Les intervalles semi-ouvert et ouverts excluent respectivement
de l’ensemble l’une ou leurs deux extrémités. Dans cette section, nous admettrons
que les intervalles sont fermés ; étendre les résultats à des intervalles ouverts ou semiouverts ne pose conceptuellement aucun problème.
Les intervalles sont pratiques pour représenter des événements qui occupent chacun une période de temps continue. On pourrait, par exemple, avoir envie d’interroger
une base de données d’intervalles de temps pour retrouver les événements qui se produisent pendant un intervalle donné. La structure de données de cette section fournit
un moyen efficace pour gérer une telle base d’intervalles.
Un intervalle [t1 , t2 ] peut être représenté par un objet i, comportant deux champs :
début[i] = t1 et fin[i] = t2 . On dit que des intervalles i et i se recoupent si i ∩ i fi ∅,
c’est-à-dire si début[i] fin[i ] et début[i ] fin[i]. Deux intervalles i et i quelconques vérifient la trichotomie d’intervalle, à savoir qu’une propriété et une seule
parmi les trois suivantes est satisfaite :
a) i et i se recoupent,
b) i est à la gauche de i (c’est-à-dire, fin[i] < début[i ]),
c) i est à la droite de i (c’est-à-dire, fin[i ] < début[i]).
14.3
Arbres d’intervalles
305
La figure 14.3 illustre ces trois possibilités.
i
i′
i
i′
i
i′
i
i′
(a)
i
i′
(b)
i′
i
(c)
Figure 14.3 La trichotomie d’intervalle pour deux intervalles fermés i et i . (a) Si i et i se refin[i ] et début[i ]
fin[i]. (b) Les
coupent, quatre cas se présentent ; pour chacun, début[i]
intervalles ne se recoupent pas, et fin[i] < début[i ]. (c) Les intervalles ne se recoupent pas, et
fin[i ] < début[i].
Un arbre d’intervalles est un arbre rouge-noir qui gère un ensemble dynamique
d’éléments, chaque élément x contenant un intervalle int[x]. Les arbres d’intervalles
autorisent les opérations suivantes :
I NSÉRER -I NTERVALLE(T, x) ajoute l’élément x, dont on suppose que le champ int
contient un intervalle, à l’arbre d’intervalles T.
S UPPRIMER -I NTERVALLE(T, x) ôte l’élément x de l’arbre d’intervalles T.
R ECHERCHER -I NTERVALLE(T, i) retourne un pointeur sur un élément x de l’arbre
d’intervalles T tel que int[x] recoupe l’intervalle i, ou la sentinelle nil[T] si un tel
élément n’existe pas dans l’ensemble.
c Dunod – La photocopie non autorisée est un délit
La figure 14.4 montre comment un ensemble d’intervalles peut être représenté par
un arbre d’intervalles. Le plan en quatre étapes de la section 14.2 va nous guider
pendant que nous reprendrons la conception d’un arbre d’intervalles et les opérations
qui s’y rattachent.
a) Étape 1 : Structure de données sous-jacente
On choisit un arbre rouge-noir dans lequel chaque nœud x contient un intervalle int[x]
et dont la clé représente l’extrémité gauche, début[int[x]]. Ainsi, par un parcours préfixe de la structure de données, on peut recenser les intervalles triés selon leur extrémité gauche.
b) Étape 2 : Information supplémentaire
En plus des intervalles eux-mêmes, chaque nœud x contient une valeur max[x], qui
représente la valeur maximale parmi les extrémités d’intervalles stockés dans le sousarbre enraciné en x.
14 • Extension d’une structure de données
306
26 26
25
19
17
19
16
21
15
8
0
23
9
6
5
30
20
10
8
3
0
5
10
15
20
25
30
(a)
[16,21]
30
[8,9]
[25,30]
23
30
int
max
[5,8]
[15,23]
[17,19]
[26,26]
10
23
20
26
[0,3]
[6,10]
3
10
[19,20]
20
(b)
Figure 14.4 Un arbre d’intervalles. (a) Un ensemble de 10 intervalles, triés du bas vers le haut
selon leur extrémité gauche. (b) L’arbre d’intervalles qui les représente. Un parcours préfixe de
l’arbre recense les nœuds en les triant suivant leur extrémité gauche.
c) Étape 3 : Mise à jour de l’information
On doit vérifier que l’insertion et la suppression peuvent s’exécuter en O(lg n) sur un
arbre d’intervalles à n nœuds. On peut déterminer max[x] connaissant un intervalle
int[x] et les valeurs max des fils du nœud x :
max[x] = max(fin[int[x]], max[gauche[x]], max[droite[x]]) .
Ainsi, d’après le théorème 14.1, l’insertion et la suppression ont un temps d’exécution
en O(lg n). En fait, la mise à jour des champs max après une rotation peut s’effectuer
en O(1), comme le montrent les exercices 14.2.4 et 14.3.1.
d) Étape 4 : Développer les nouvelles opérations
La seule opération nouvelle nécessaire est I NTERVALLE -R ECHERCHER(T, i), qui
trouve dans l’arbre T un nœud dont l’intervalle recoupe l’intervalle i. S’il n’existe
aucun intervalle de ce type, on retourne un pointeur vers la sentinelle nil[T].
14.3
Arbres d’intervalles
307
R ECHERCHER -I NTERVALLE(T, i)
1 x ← racine[T]
2 tant que x fi nil[T] et i ne recoupe pas int[x]
3
faire si gauche[x] fi nil[T] et max[gauche[x]] début[i]
4
alors x ← gauche[x]
5
sinon x ← droite[x]
6 retourner x
La recherche d’un intervalle recoupant i commence avec x à la racine de l’arbre, et
continue en descendant. Elle se termine soit quand un recoupement d’intervalles est
trouvé, soit quand x pointe vers la sentinelle nil[T]. Comme chaque itération de la
boucle principale prend un temps en O(1), et que la hauteur d’un arbre rouge-noir à
n nœuds est O(lg n), la procédure R ECHERCHER -I NTERVALLE prend O(lg n).
c Dunod – La photocopie non autorisée est un délit
Avant de vérifier la validité de R ECHERCHER -I NTERVALLE, examinons son fonctionnement sur l’arbre d’intervalles de la figure 14.4. Supposons qu’on veuille trouver
un intervalle qui recoupe l’intervalle i = [22, 25]. On commence avec x à la racine,
qui contient [16, 21], et ne recoupe pas i. Comme max[gauche[x]] = 23 est plus grand
que début[i] = 22, la boucle continue en prenant pour x la valeur du fils gauche de la
racine, à savoir le nœud contenant l’intervalle [8, 9], qui ne recoupe pas non plus i.
Cette fois, max[gauche[x]] = 10 est inférieur à début[i] = 22, et la boucle continue en
prenant comme nouvel x le fils droit de x. L’intervalle [15, 23] stocké dans ce nœud
recoupe i, et c’est donc ce nœud qui est retourné par la procédure.
Comme exemple de recherche infructueuse, supposons qu’on souhaite trouver un
intervalle qui recoupe i = [11, 14] dans l’arbre d’intervalles de la figure 14.4. On
commence une fois encore en prenant pour x la racine de l’arbre. Comme l’intervalle [16, 21] situé à la racine ne recoupe pas i, et que max[gauche[x]] = 23 est plus
grand que début[i] = 11, on se dirige vers la gauche, sur le nœud contenant l’intervalle [8, 9]. (Notez qu’aucun intervalle du sous-arbre droit ne recoupe i, nous verrons
pourquoi plus tard.) L’intervalle [8, 9] ne recoupe pas i, et max[gauche[x]] = 10 est
inférieur à début[i] = 11, ce qui nous dirige vers la droite. (Notez qu’aucun intervalle
du sous-arbre gauche ne recoupe i.) L’intervalle [15, 23] ne recoupe pas i, et son fils
gauche vaut nil[T] ; on prend donc à droite, la boucle se termine, et la sentinelle nil[T]
est retournée.
Pour vérifier la validité de R ECHERCHER -I NTERVALLE, on doit comprendre qu’il
suffit d’examiner un chemin unique partant de la racine. Le principe est qu’à un nœud
x quelconque, si int[x] ne recoupe pas i, la recherche continue toujours dans la bonne
direction : nous sommes sûrs de trouver un recoupement s’il en existe un dans l’arbre.
Le théorème suivant établit cette propriété plus précisément.
Théorème 14.2 Chaque exécution de R ECHERCHER -I NTERVALLE(T, i) soit retourne
un nœud dont l’intervalle recoupe i, soit retourne nil[T] et l’arbre T ne contient
aucun nœud dont l’intervalle recoupe i.
14 • Extension d’une structure de données
308
i′′
i′′
i′
i′
i
i
(a)
i′′
i′
(b)
Figure 14.5 Intervalles dans la démonstration du théorème 14.2. La valeur de max[gauche[x]]
est représentée dans chaque cas par une ligne pointillée. (a) La recherche se prolonge vers la
droite. Aucun intervalle i ne peut recouper i. (b) La recherche se prolonge vers la gauche. Le
sous-arbre gauche de x contient un intervalle qui recoupe i (situation non représentée), ou il
existe un intervalle i dans le sous-arbre gauche de x tel que fin[i ] = max[gauche[x]]. Comme i ne
recoupe pas i , il ne recoupe pas non plus un autre intervalle i quelconque du sous-arbre droit
de x, puisque début[i ] début[i ].
Démonstration : La boucle tant que des lignes 2–5 se termine soit quand x = nil[T],
soit quand i recoupe int[x]. Dans le dernier cas, on sait avec certitude qu’il faut retourner x. Concentrons-nous donc sur le premier cas, où la boucle tant que se termine
parce que x = nil[T].
Nous emploierons l’invariant suivant pour la boucle tant que des lignes 2–5 :
Si l’arbre T contient un intervalle qui recoupe i, alors il existe un tel intervalle
dans le sous-arbre issu de x.
Nous utiliserons cet invariant comme suit :
Initialisation : Avant la première itération, la ligne 1 prend pour x la racine de T, de
sorte que l’invariant est vrai.
Conservation : Dans chaque itération de la boucle tant que, il y a exécution soit de
la ligne 4, soit de la ligne 5. Nous allons montrer que l’invariant est conservé dans
l’un ou l’autre cas.
Si c’est la ligne 5 qui est exécutée, compte tenu de la condition de branchement en ligne 3, on a gauche[x] = nil[T], ou max[gauche[x]] < début[i]. Si
gauche[x] = nil[T], le sous-arbre issu de gauche[x] ne contient visiblement aucun
intervalle qui recoupe i, et donc prendre pour x la valeur droite[x] conserve l’invariant. Supposons donc que gauche[x] fi nil[T] et max[gauche[x]] < début[i].
Comme le montre la figure 14.5(a), pour chaque intervalle i du sous-arbre gauche
de x, on a
fin[i ]
max[gauche[x]]
< début[i] .
D’après la trichotomie d’intervalle, i et i ne se recoupent donc pas. Par conséquent,
le sous-arbre gauche de x ne contient aucun intervalle qui recoupe i ; donc, prendre
pour x la valeur droite[x] conserve l’invariant.
Si, en revanche, c’est la ligne 4 qui est exécutée, alors on va montrer que la contraposée de l’invariant est vraie. C’est-à-dire que l’on va montrer que, s’il n’existe aucun intervalle recoupant i dans le sous-arbre issu de gauche[x], alors nulle part dans
l’arbre il n’y a d’intervalle qui recoupe i. Comme la ligne 4 est exécutée, compte
14.3
Arbres d’intervalles
309
tenu de la condition de branchement en ligne 3, on a max[gauche[x]] début[i].
En outre, d’après la définition du champ max, il doit obligatoirement exister un
certain intervalle i dans le sous-arbre gauche de x tel que
fin[i ]
=
max[gauche[x]]
début[i] .
(La figure 14.5(b) illustre cette situation.) Comme i et i ne se recoupent pas, et
comme il n’est pas vrai que fin[i ] < début[i], il s’ensuit, d’après la trichotomie
d’intervalle, que fin[i] < début[i ]. Comme les clés d’un arbre d’intervalles sont
les débuts des intervalles, la propriété d’arbre de recherche implique que, pour tout
intervalle i du sous-arbre droit de x’,
fin[i] <
début[i ]
début[i ] .
D’après la trichotomie d’intervalle, i et i ne se recoupent pas. On en conclut que,
qu’il y ait ou non un intervalle du sous-arbre gauche de x’ qui recoupe i, prendre
pour x la valeur gauche[x] conserve l’invariant.
Terminaison : Si la boucle se termine quand x = nil[T], il n’y a pas d’intervalle qui
recoupe i dans le sous-arbre issu de x. La contraposée de l’invariant de boucle
implique que T ne contient aucun intervalle qui recoupe i. Il est donc correct de
retourner x = nil[T].
❑
Par conséquent, la procédure R ECHERCHER -I NTERVALLE fonctionne correctement.
Exercices
c Dunod – La photocopie non autorisée est un délit
14.3.1 Écrire un pseudo code pour ROTATION -G AUCHE agissant sur les nœuds d’un arbre
d’intervalles et capable de mettre à jour les champs max en O(1).
14.3.2 Réécrire le code de R ECHERCHER -I NTERVALLE pour qu’il fonctionne correctement
lorsque tous les intervalles sont supposés ouverts.
14.3.3 Décrire un algorithme efficace qui, étant donné un intervalle i, retourne un intervalle
recoupant i qui a l’extrémité initiale minimale, ou qui retourne nil[T] si un tel intervalle
n’existe pas.
14.3.4 Étant donné un arbre d’intervalles T et un intervalle i, décrire comment tous les intervalles de T recoupant i peuvent être recensés en O(min(n, k lg n)), où k est le nombre d’intervalles présents dans la liste de sortie. (Optionnel : Trouver une solution qui ne modifie pas
l’arbre.)
310
14 • Extension d’une structure de données
14.3.5 Suggérer des modifications aux procédures d’arbre d’intervalles permettant de supporter l’opération R ECHERCHER -I NTERVALLE -E XACT(T, i), qui retourne un pointeur sur
un nœud x de l’arbre d’intervalles T tel que début[int[x]] = début[i] et fin[int[x]] = fin[i],
ou qui retourne nil[T] si T ne contient pas un tel nœud. Toutes les opérations, y compris
R ECHERCHER -I NTERVALLE -E XACT, devront s’exécuter dans un temps en O(lg n) sur un
arbre à n nœuds.
14.3.6 Montrer comment gérer un ensemble dynamique Q de nombres pouvant supporter
l’opération D ISTANCE -M IN, qui donne la longueur de la différence entre les deux nombres
les plus proches dans Q. Par exemple, si Q = {1, 5, 9, 15, 18, 22}, alors D ISTANCE -M IN(Q)
retourne 18 − 15 = 3, puisque 15 et 18 sont les nombres les plus proches dans Q. Rendre
les opérations I NSÉRER, S UPPRIMER, R ECHERCHER et D ISTANCE -M IN les plus efficaces
possibles, et analyser leur temps d’exécution.
14.3.7 Les bases de données VLSI représentent souvent un circuit intégré par une liste de
rectangles. On admet que chaque rectangle est orienté de façon rectilinéaire (côtés parallèles
aux axes x et y), de manière qu’on puisse représenter un rectangle par ses coordonnées x et y
minimales et maximales. Donner un algorithme en O(n lg n) permettant de décider si un ensemble de rectangles représentés de cette manière contient deux rectangles qui se recoupent.
Votre algorithme n’a pas besoin de donner toutes les paires ayant une surface commune, mais
il doit signaler qu’il y a recoupement s’il existe un rectangle qui recouvre entièrement un
autre, même si leurs frontières ne se coupent pas. (Conseil : Déplacer une ligne de « balayage » sur l’ensemble des rectangles.)
PROBLÈMES
14.1. Point de recoupement maximal
On veut gérer le point de recoupement maximal d’un ensemble d’intervalles, c’està-dire le point recouvert par le plus grand nombre d’intervalles de la base de données.
a. Montrer qu’il y a toujours un point de recoupement maximal qui est une extrémité
de l’un des segments.
b. Concevoir une structure de données qui permette de gérer efficacement les opérations I NSÉRER -I NTERVALLE, S UPPRIMER -I NTERVALLE et T ROUVER -PRM ;
cette dernière procédure retourne un point de recoupement maximal. (conseil :
Gérer un arbre rouge-noir contenant toutes les extrémités. Associer la valeur +1
à chaque extrémité gauche et la valeur −1 à chaque extrémité droite. Augmenter
chaque nœud de l’arbre en y ajoutant des données supplémentaires pour gérer le
point de recoupement maximal.)
Notes
311
14.2. Permutation de Josephus
Le problème de Josephus se définit de la façon suivante. On a n personnes rangées en
cercle et un entier positif m n. En commençant par une personne désignée, on fait
le tour du cercle en éliminant une personne toutes les m. Après chaque élimination, on
continue à compter avec le cercle qui reste. Le processus se poursuit jusqu’à ce que les
n personnes aient été éliminées. L’ordre dans lequel les personnes sont éliminées du
cercle définit la permutation de Josephus (n, m) des entiers 1, 2, . . . , n. Par exemple,
la permutation de Josephus (7, 3) est 3, 6, 2, 7, 5, 1, 4.
a. Supposons que m soit une constante. Décrire un algorithme en O(n) qui, étant
donné un entier n, donne la permutation de Josephus (n, m).
b. On suppose que m n’est pas une constante. Décrire un algorithme en O(n lg n) qui,
étant donnés les entiers n et m, donne la permutation de Josephus (n, m).
NOTES
c Dunod – La photocopie non autorisée est un délit
Dans leur livre, Preparata et Shamos [247] décrivent plusieurs types d’arbres d’intervalles
que l’on trouve dans les manuels, citant notamment les travaux de H. Edelsbrunner (1980) et
E. M. McCreight (1981). Le livre détaille un arbre d’intervalles pour lequel, étant donnée une
base de données statique de n intervalles, les k intervalles qui recoupent un certain intervalle
donné sont énumérables en temps O(k + lg n).
PARTIE 4
c Dunod – La photocopie non autorisée est un délit
TECHNIQUES AVANCÉES
DE CONCEPTION ET D’ANALYSE
Cette partie aborde trois techniques importantes pour la conception et l’analyse
d’algorithmes efficaces : la programmation dynamique (chapitre 15), les algorithmes
gloutons (chapitre 16) et l’analyse amortie (chapitre 17). Les parties précédentes
ont présenté d’autres techniques largement utilisées, comme l’approche diviserpour-régner, la randomisation et les récurrences. Les nouvelles techniques sont un
peu plus sophistiquées, mais elles sont essentielles pour s’attaquer efficacement
à de nombreux problèmes informatiques. Les thèmes introduits dans cette partie
réapparaîtront à plusieurs reprises dans la suite de ce livre.
La programmation dynamique s’applique le plus souvent à des problèmes d’optimisation dans lesquels on doit faire un ensemble de choix pour arriver à une solution optimale. À mesure que l’on fait des choix, on voit souvent surgir des sousproblèmes de la même forme. La programmation dynamique est efficace lorsqu’un
sous-problème donné est susceptible d’être engendré par plus d’un ensemble partiel
de choix ; le principe fondamental ici est de mémoriser la solution d’un tel sousproblème, pour le cas où il réapparaîtrait. Le chapitre 15 montrera comment cette
idée simple peut parfois transformer des algorithmes à temps exponentiel en algorithmes à temps polynomial.
À l’instar des algorithmes de la programmation dynamique, les algorithmes gloutons s’appliquent en général à des problèmes d’optimisation dans lesquels il faut
effectuer un ensemble de choix pour arriver à une solution optimale. Le principe
d’un algorithme glouton est de faire chaque choix d’une manière qui soit localement
314
Partie 4
optimale. Un exemple simple en est donné par l’opération consistant à rendre la monnaie : pour minimiser le nombre de pièces utilisées pour rendre la monnaie pour un
montant donné, il suffit de choisir de manière répétée la pièce la plus forte inférieure
ou égale au reste dû. Il existe de nombreux problèmes de cette sorte pour lesquels une
approche gloutonne fournit une solution optimale bien plus rapidement que ne le ferait la programmation dynamique. Toutefois, il n’est pas toujours facile de prédire si
une approche gloutonne serait efficace. Le chapitre 16 donne un aperçu de la théorie
des matroïdes qui facilite souvent ce genre d’estimation.
L’analyse amortie permet d’analyser un algorithme qui effectue une séquence
d’opérations similaires. Au lieu de borner le coût de la séquence en bornant le coût
réel de chaque opération séparément, on utilise une analyse amortie pour borner le
coût réel de la séquence globale. L’idée sous-jacente ici est que toutes les opérations
de la séquence ne vont pas s’effectuer systématiquement dans le contexte du cas
le plus défavorable. Certaines opérations seront coûteuses, mais d’autres non. Par
ailleurs, l’analyse amortie n’est pas seulement un outil d’analyse ; c’est aussi une
technique de conception d’algorithmes, car conception et analyse du temps d’exécution sont des concepts qui sont souvent très imbriqués. Le chapitre 17 présentera
trois façons de faire l’analyse amortie d’un algorithme.
Chapitre 15
c Dunod – La photocopie non autorisée est un délit
Programmation dynamique
La programmation dynamique, comme la méthode diviser-pour-régner, résout des
problèmes en combinant des solutions de sous-problèmes. (« Programmation », dans
ce contexte, fait référence à une méthode tabulaire et non à l’écriture de code informatique.). Comme nous l’avons vu au chapitre 2, les algorithmes diviser-pour-régner
partitionnent le problème en sous-problèmes indépendants qu’ils résolvent récursivement, puis combinent leurs solutions pour résoudre le problème initial. La programmation, quant à elle, peut s’appliquer même lorsque les sous-problèmes ne sont pas
indépendants, c’est-à-dire lorsque des sous-problèmes ont des sous-sous-problèmes
communs. Dans ce cas, un algorithme diviser-pour-régner fait plus de travail que nécessaire, en résolvant plusieurs fois le sous-sous-problème commun. Un algorithme
de programmation dynamique résout chaque sous-sous-problème une seule fois et
mémorise sa réponse dans un tableau, évitant ainsi le recalcul de la solution chaque
fois que le sous-sous-problème est rencontré.
La programmation dynamique est, en général, appliquée aux problèmes d’optimisation. Dans ce type de problèmes, il peut y avoir de nombreuses solutions possibles.
Chaque solution a une valeur, et on souhaite trouver une solution ayant la valeur
optimale (minimale ou maximale). Une telle solution est une solution optimale au
problème, et non pas la solution optimale, puisqu’il peut y avoir plusieurs solutions
qui donnent la valeur optimale.
Le développement d’un algorithme de programmation dynamique peut être découpé en quatre étapes.
1) Caractériser la structure d’une solution optimale.
316
15 • Programmation dynamique
2) Définir récursivement la valeur d’une solution optimale.
3) Calculer la valeur d’une solution optimale de manière ascendante (bottom-up).
4) Construire une solution optimale à partir des informations calculées.
Les étapes 1–3 forment la base d’une résolution de problème à la mode de la programmation dynamique. On peut omettre l’étape 4 si l’on n’a besoin que de la valeur
d’une solution optimale. Lorsqu’on effectue l’étape 4, on gère parfois des informations supplémentaires pendant le calcul de l’étape 3 pour faciliter la construction
d’une solution optimale.
Les sections suivantes utilisent la programmation dynamique pour résoudre certains problèmes d’optimisation. La section 15.1 étudie un problème concernant l’ordonnancement de deux chaînes de montage d’automobiles où, après chaque poste,
l’auto en cours de fabrication peut rester sur la même chaîne ou passer sur l’autre
chaîne. La section 15.2 montre comment multiplier une suite de matrices avec le
moins possible de multiplications scalaires. Partant de ces exemples de programmation dynamique, la section 15.3 étudie deux grandes caractéristiques que doit posséder un problème pour que la programmation dynamique soit une technique de
résolution viable. La section 15.4 montre ensuite comment trouver la plus longue
sous-séquence commune de deux séquences. Enfin, la section 15.5 emploie la programmation dynamique pour construire des arbres binaires de recherche qui sont
optimaux, étant donnée une distribution connue des clés à chercher.
15.1 ORDONNANCEMENT DE CHAÎNES DE MONTAGE
Notre premier exemple de programmation dynamique traite d’un problème de fabrication. La firme Facel-Véga produit des automobiles dans un atelier qui a deux
chaînes de montage (voir figure 15.1). Un châssis arrive sur chaque chaîne, puis passe
par un certain nombre de postes où on lui ajoute des pièces ; une fois terminée, l’auto
sort par l’autre extrémité de la chaîne. Chaque chaîne de montage a n stations, numérotées j = 1, 2, . . . , n. On représente le jème poste de la chaîne i (avec i égale 1 ou 2)
par Si,j . Le jème poste de la chaîne 1 (S1,j ) fait le même travail que le jème poste de
la chaîne 2 (S2,j ). Les postes ont été installés à des époques différentes et avec des
technologies différentes ; ainsi, le temps de montage varie d’un poste à l’autre, même
quand il s’agit de postes fonctionnement identiques mais situés sur des chaînes différentes. Le temps de montage au poste Si,j est ai,j . Comme le montre la figure 15.1,
un châssis arrive au poste 1 de l’une des chaînes, puis passe de poste en poste. On a
aussi un temps d’arrivée ei pour le châssis qui entre sur la chaîne i, et un temps de
sortie xi pour l’auto achevée qui sort de la chaîne i.
Normalement, une fois qu’un châssis arrive sur une chaîne de montage, il ne circule que sur cette chaîne. Le temps de passage d’un poste à l’autre sur une même
chaîne est négligeable. En cas d’urgence, toutefois, il se peut que l’on veuille accélérer le délai de fabrication d’une automobile. En pareil cas, le châssis transite toujours
15.1
Ordonnancement de chaînes de montage
poste S1,1
chaîne 1
a1,1
poste S1,2
a1,2
poste S1,3
a1,3
317
poste S1,4
poste S1,n–1
a1,4
a1,n–1
poste S1,n
a1,n
e1
x1
entrée
des
chassis
t1,1
t1,2
t1,3
t2,1
t2,2
t2,3
t1,n–1
…
sortie
des
autos
t2,n–1
e2
chaîne 2
x2
a2,1
poste S2,1
a2,2
poste S2,2
a2,3
poste S2,3
a2,4
poste S2,4
a2,n–1
poste S2,n–1
a2,n
poste S2,n
c Dunod – La photocopie non autorisée est un délit
Figure 15.1 Problème de fabrication pour déterminer le chemin optimal dans une usine. Il y a
deux chaînes de montage, ayant chacune n postes ; le jème poste de la chaîne i est noté Si,j et le
temps de montage à ce poste est ai,j . Un châssis entre dans l’atelier, puis va sur la chaîne i (avec
i = 1 ou 2) en mettant un temps ei . Après être passé par le jème poste d’une chaîne, le châssis va
sur le (j + 1)ème poste de l’une ou l’autre chaîne. Il n’y a pas de coût de transfert si l’auto reste sur
la même chaîne, mais il faut un temps ti,j pour passer sur l’autre chaîne après le poste Si,j . Après
avoir quitté le nème poste d’une chaîne, l’auto achevée met un temps xi pour sortir de l’atelier.
Le problème consiste à déterminer quels sont les postes à sélectionner sur la chaîne 1 et sur la
chaîne 2 pour minimiser le délai de transit d’une auto à travers l’atelier.
par les n postes dans l’ordre, mais le chef d’atelier peut faire passer une auto partiellement construite d’une chaîne à l’autre, et ce après chaque poste. Le temps de
transfert d’un châssis depuis la chaîne i et après le poste Si,j est ti,j , avec i = 1, 2
et j = 1, 2, . . . , n − 1 (car, après le nème poste, c’est fini). Le problème consiste
à déterminer quels sont les postes à sélectionner sur la chaîne 1 et sur la chaîne 2
pour minimiser le délai de transit d’une auto à travers l’atelier. Sur l’exemple de la
figure 15.2(a), le délai optimal est obtenu via sélection des postes 1, 3 et 6 de la
chaîne 1 et des postes 2, 4 et 5 de la chaîne 2.
La solution évidente et « primaire », pour minimiser le délai de circulation à travers
l’atelier, est irréaliste quand il y beaucoup de postes. Connaissant la liste des postes à
utiliser sur la chaîne 1 et des postes à utiliser sur la chaîne 2, il est facile de calculer en
temps Q(n) le délai de transit d’un châssis à travers l’atelier. Malheureusement, il y
a 2n façons possibles de choisir les postes ; on peut le voir en considérant l’ensemble
des postes utilisés sur la chaîne 1 comme un sous-ensemble de {1, 2, . . . , n} et en
remarquant qu’il y a 2n tels sous-ensembles. Par conséquent, déterminer le chemin
le plus rapide en énumérant tous les chemins possibles puis en calculant la durée de
chacun, c’est une solution qui prend un temps V(2n ), ce qui est irréaliste quand n est
grand.
a) Étape 1 : structure du chemin optimal à travers l’atelier
La première étape du paradigne de la programmation dynamique est de caractériser la
structure d’une solution optimale. Pour le problème de l’ordonnancement de chaîne
de montage, voici comment on peut procéder à cette étape. Considérons le chemin
15 • Programmation dynamique
318
chaîne 1
posteS1,1
poste S1,2
poste S1,3
poste S1,4
poste S1,5
poste S1,6
7 1
9
3
4
8
4
2
3
entrée
des
chassis
2
3
1
3
4
2
1
2
2
1
sortie
des
autos
4
2
chaîne 2
8
5
poste S2,1
6
poste S2,2
4
poste S2,3
5
poste S2,4
7
poste S2,5
poste S2,6
(a)
j
f1[j]
f2[j]
1
2
3
4
5
6
9 18 20 24 32 35
12 16 22 25 30 37
f * = 38
j
2
3
4
5
6
l1[j]
l2[j]
1
1
2
2
1 1
1 2
2
2
l* = 1
(b)
Figure 15.2 (a) Une instance du problème de la chaîne de montage, avec les coûts ei , ai,j , ti,j
et xi affichés. Le chemin sur fond gris foncé indique le chemin le plus rapide à travers l’atelier.
(b) Les valeurs de fi [j], f ∗ , li [j] et l∗ pour l’instance de la partie (a).
optimal pour aller du point de départ au poste S1,j . Si j = 1, il n’y a qu’un seul chemin
possible et il est donc facile de déterminer le temps qu’il faut pour arriver au poste S1,j .
Pour j = 2, 3, . . . , n, en revanche, il y a deux possibilités : le châssis peut aller du
poste S1,j−1 au poste S1,j directement, le délai de passage du poste j − 1 au poste j de
la même chaîne étant négligeable. Mais le châssis peut aussi aller du poste S2,j−1 au
poste S1,j , le délai de transfert étant alors t2,j−1 . Nous traiterons séparément ces deux
cas de figure, bien qu’ils aient beaucoup de points communs comme nous le verrons.
Primo, supposons que le chemin optimal vers le poste S1,j passe par le poste S1,j−1 .
La remarque fondamentale est que le châssis a forcément pris un chemin optimal pour
aller du point de départ au poste S1,j−1 . Pourquoi ? S’il existait un chemin plus rapide
pour aller au poste S1,j−1 , on pourrait utiliser ce chemin plus rapide pour obtenir un
chemin plus rapide vers le poste S1,j ; d’où une contradiction.
De même, supposons maintenant que le chemin optimal vers le poste S1,j passe
par le poste S2,j−1 . Le châssis a forcément pris un chemin optimal du point de départ
au poste S2,j−1 . Le raisonnement est le même : s’il existait un chemin plus rapide
menant au poste S2,j−1 , on utiliserait ce chemin plus rapide pour obtenir un chemin
plus rapide vers le poste S1,j ; d’où une contradiction.
Plus généralement, on peut dire que, pour l’ordonnancement de chaîne de montage, une solution optimale à un problème (trouver le chemin optimal menant au
15.1
Ordonnancement de chaînes de montage
319
poste Si,j ) contient en elle une solution optimale pour des sous-problèmes (trouver le
chemin optimal menant à S1,j−1 ou S2,j−1 ). On parle ici de propriété de sous-structure
optimale, et c’est l’un des grands critères de l’applicabilité de la programmation dynamique, comme nous le verrons à la section 15.3.
On emploie la sous-structure optimale pour montrer que l’on peut construire une
solution optimale pour un problème à partir de solutions optimales pour des sousproblèmes. Pour l’ordonnancement de chaîne de montage, on raisonne comme suit.
Si l’on examine un chemin optimal menant au poste S1,j , il doit passer par le poste j−1
de l’une des chaînes 1 et 2. Donc, le chemin optimal passant par le poste S1,j est soit
– le chemin optimal passant par le poste S1,j−1 puis allant directement au poste S1,j ,
soit
– le chemin optimal passant par le poste S2,j−1 , sautant de la chaîne 2 à la chaîne 1,
puis allant au poste S1,j .
Un raisonnement symétrique dit que le chemin optimal passant par le poste S2,j est
soit
– le chemin optimal passant par le poste S2,j−1 puis allant directement au poste S2,j ,
soit
– le chemin optimal passant par le poste S1,j−1 , sautant de la chaîne 1 à la chaîne 2,
puis allant au poste S2,j .
Pour résoudre le problème du calcul du chemin optimal passant par le poste j de
l’une ou l’autre chaîne, on résout les sous-problèmes consistant à calculer les chemins
optimaux passant par le poste j − 1 des deux chaînes de montage.
Par conséquent, on peut construire une solution optimale d’une instance de l’ordonnancement de chaîne de montage partir de la construction de solutions optimales
pour des sous-problèmes.
c Dunod – La photocopie non autorisée est un délit
b) Étape 2 : une solution récursive
La deuxième étape du paradigne de la programmation dynamique consiste à définir
la valeur d’une solution optimale de manière récursive à partir de solutions optimales
de sous-problèmes. Pour le problème de l’ordonnancement de chaîne de montage, on
choisit comme sous-problèmes les problèmes consistant à trouver le chemin optimal
passant par le poste j des deux chaînes, pour j = 1, 2, . . . , n. Soit fi [j] le délai le plus
court possible avec lequel le châssis va du point de départ au poste Si,j .
Notre objectif ultime est de déterminer le délai le plus court par lequel un châssis
traverse tout l’atelier, délai que nous noterons f ∗ . Le châssis doit aller au poste n de
l’une ou l’autre des chaînes 1 et 2, et de là aller vers la sortie de l’atelier. Comme le
plus rapide de ces chemins est le chemin optimal à travers tout l’atelier, on a
f ∗ = min(f1 [n] + x1 , f2 [n] + x2 ) .
(15.1)
15 • Programmation dynamique
320
Il est facile aussi de raisonner sur f1 [1] et f2 [1]. Pour passer par le poste 1 d’une
des deux chaînes, un châssis va tout simplement vers ce poste directement. Donc,
f1 [1] = e1 + a1,1 ,
f2 [1] = e2 + a2,1 .
(15.2)
(15.3)
Voyons maintenant comment calculer fi [j] pour j = 2, 3, . . . , n (et i = 1, 2). En nous
focalisant sur f1 [j], rappelons-nous que le chemin optimal passant par le poste S1,j
est soit le chemin optimal passant par le poste S1,j−1 suivi du passage direct au
poste S1,j , soit le chemin optimal passant par le poste S2,j−1 suivi d’un transfert de la
chaîne 2 à la chaîne 1 et suivi enfin du passage au poste S1,j . Dans le premier cas, on
a f1 [j] = f1 [j − 1] + a1,j ; dans le dernier cas, on a f1 [j] = f2 [j − 1] + t2,j−1 + a1,j . Par
conséquent,
f1 [j] = min(f1 [j − 1] + a1,j , f2 [j − 1] + t2,j−1 + a1,j )
(15.4)
pour j = 2, 3, . . . , n. De manière symétrique, on a
f2 [j] = min(f2 [j − 1] + a2,j , f1 [j − 1] + t1,j−1 + a2,j )
(15.5)
pour j = 2, 3, . . . , n. En combinant les équations (15.2)–(15.5), on obtient les équations récursives
si j = 1 ,
e1 + a1,1
(15.6)
f1 [j] =
min(f1 [j − 1] + a1,j , f2 [j − 1] + t2,j−1 + a1,j ) si j 2
f2 [j] =
e2 + a2,1
si j = 1 ,
min(f2 [j − 1] + a2,j , f1 [j − 1] + t1,j−1 + a2,j ) si j 2
(15.7)
La figure 15.2(b) montre les valeurs fi [j] pour l’exemple de la partie (a), telles que
calculées par les équations (15.6) et (15.7), avec en plus la valeur de f ∗ .
Les valeurs fi [j] donnent les valeurs de solutions optimales pour des sousproblèmes. Pour nous aider à comprendre comment on construit une solution
optimale, définissons li [j] comme étant le numéro de la chaîne de montage, 1 ou 2,
dont le poste j − 1 est utilisé par un chemin optimal passant par le poste Si,j . Ici,
i = 1, 2 et j = 2, 3, . . . , n. (On évite de définir li [1], car aucun poste ne vient avant
le poste 1 sur l’une ou l’autre chaîne.) On définit aussi l∗ comme étant la chaîne
dont le poste n est utilisé par un chemin optimal traversant tout l’atelier. Les valeurs
li [j] facilitent le traçé d’un chemin optimal. En utilisant les valeurs de l∗ et de li [j]
montrées à la figure 15.2(b), voici comment on tracerait un chemin optimal à travers
l’atelier de la partie (a). On part avec l∗ = 1, et on utilise le poste S1,6 . On regarde
ensuite l1 [6] qui vaut 2, et donc on utilise le poste S2,5 . En continuant, on regarde
l2 [5] = 2 (utiliser poste S2,4 ), l2 [4] = 1 (poste S1,3 ), l1 [3] = 2 (poste S2,2 ) et l2 [2] = 1
(poste S1,1 ).
15.1
Ordonnancement de chaînes de montage
321
c) Étape 3 : calcul des temps optimaux
À ce stade, ce serait chose simple que d’écrire un algorithme récursif, à partir de
l’équation (15.1) et des récurrences (15.6) et (15.7), pour calculer le chemin optimal
à travers l’atelier. Il y a un hic avec un tel algorithme récursif : son temps d’exécution
est exponentiel en n. Pour comprendre pourquoi, notons ri (j) le nombre de références
faites à fi [j] dans un algorithme récursif. Partant de l’équation (15.1), on a
r1 (n) = r2 (n) = 1 .
(15.8)
Partant des récurrences (15.6) et (15.7), on a
r1 (j) = r2 (j) = r1 (j + 1) + r2 (j + 1)
(15.9)
pour j = 1, 2, . . . , n − 1. Ainsi que l’exercice 15.1.2 vous demandera de le montrer, ri (j) = 2n−j . Donc, f1 [1] à elle seule est référencée 2n−1 fois ! Comme l’exercice 15.1.3 vous demandera de le montrer, le nombre total de références à toutes les
valeurs fi [j] est Q(2n ).
c Dunod – La photocopie non autorisée est un délit
On peut faire beaucoup mieux, en calculant les valeurs fi [j] dans un ordre différent
de la façon récursive. Notez que, pour j 2, chaque valeur de fi [j] dépend uniquement des valeurs de f1 [j − 1] et de f2 [j − 1]. En calculant les valeurs fi [j] par ordre
croissant de numéros j de poste (de la gauche vers la droite, sur la figure 15.2(b)),
on peut calculer le chemin optimal à travers l’atelier, et le temps qu’il prend, en un
temps Q(n). La procédure P LUS -R APIDE -C HEMIN prend en entrée les valeurs ai,j ,
ti,j , ei et xi , plus n, nombre de postes de chaque chaîne de montage.
P LUS -R APIDE -C HEMIN(a, t, e, x, n)
1 f1 [1] ← e1 + a1,1
2 f2 [1] ← e2 + a2,1
3 pour j ← 2 à n
4
faire si f1 [j − 1] + a1,j f2 [j − 1] + t2,j−1 + a1,j
5
alors f1 [j] ← f1 [j − 1] + a1,j
6
l1 [j] ← 1
7
sinon f1 [j] ← f2 [j − 1] + t2,j−1 + a1,j
8
l1 [j] ← 2
9
si f2 [j − 1] + a2,j f1 [j − 1] + t1,j−1 + a2,j
10
alors f2 [j] ← f2 [j − 1] + a2,j
11
l2 [j] ← 2
12
sinon f2 [j] ← f1 [j − 1] + t1,j−1 + a2,j
13
l2 [j] ← 1
14 si f1 [n] + x1 f2 [n] + x2
15
alors f ∗ = f1 [n] + x1
16
l∗ = 1
17
sinon f ∗ = f2 [n] + x2
18
l∗ = 2
322
15 • Programmation dynamique
P LUS -R APIDE -C HEMIN fonctionne de la manière suivante. Les lignes 1–2 calculent
f1 [1] et f2 [1] en utilisant les équations (15.2) et (15.3). Ensuite, la boucle pour des
lignes 3–13 calcule fi [j] et li [j] pour i = 1, 2 et j = 2, 3, . . . , n. Les lignes 4–8 calculent
f1 [j] et l1 [j] en utilisant les équation (15.4), et les lignes 9–13 calculent f2 [j] et l2 [j]
en utilisant l’équation (15.5). Enfin, les lignes 14–18 calculent f ∗ et l∗ en utilisant
l’équation (15.1). Comme les lignes 1–2 et 14–18 prennent un temps constant et
que chacune des n − 1 itérations de la boucle pour des lignes 3–13 prend un temps
constant, l’ensemble de la procédure prend un temps Q(n).
Une façon de voir le calcul des valeurs de fi [j] et li [j] est de considérer que l’on
saisit des données dans un tableau. Si l’on prend l’exemple de la figure 15.2(b), on
remplit des tableaux contenant les valeurs fi [j] et li [j] de gauche à droite (et de haut
en bas dans chaque colonne). Pour remplir une case fi [j], on a besoin des valeurs de
f1 [j − 1] et de f2 [j − 1] ; ensuite, sachant que l’on a déjà calculé et mémorisé ces
valeurs, on les détermine rien qu’en consultant la table.
d) Étape 4 : construction du chemin optimal à travers l’atelier
Ayant calculé les valeurs de fi [j], f ∗ , li [j] et l∗ , on doit ensuite construire la séquence
des postes utilisés par le chemin optimal traversant l’atelier. On a vu comment faire
sur l’exemple de la figure 15.2.
La procédure suivante affiche les postes utilisés, par ordre décroissant de numéro
de poste. L’exercice 15.1.1 vous demandera de modifier cette procédure pour qu’elle
affiche les postes par ordre croissant de numéro.
A FFICHER -P OSTES(l, n)
1 i ← l∗
2 afficher « chaîne » i « , poste » n
3 pour j ← n jusqu’à 2
4
faire i ← li [j]
5
afficher « chaîne » i « ,poste » j − 1
Dans l’exemple de la figure 15.2, A FFICHER -P OSTES sortirait le résultat que voici
chaîne 1, poste 6
chaîne 2, poste 5
chaîne 2, poste 4
chaîne 1, poste 3
chaîne 2, poste 2
chaîne 1, poste 1
Exercices
15.1.1 Montrer comment modifier A FFICHER -P OSTES pour qu’elle affiche les postes par
ordre de numéro croisant. (Conseil : Utiliser la récursivité.)
15.2
Multiplications matricielles enchaînées
323
15.1.2 Utiliser les équations (15.8) et (15.9), ainsi que la méthode de substitution, pour
montrer que ri (j), nombre de références faites à fi [j] dans un algorithme récursif, est égal à
2n−j .
15.1.3 En utilisant le résultat de l’exercice 15.1.2, montrer que le nombre total de références
2 n
à toutes les valeurs fi [j], soit i=1 j=1 ri (j), vaut exactement 2n+1 − 2.
15.1.4 Pris ensemble, les tableaux contenant les valeurs fi [j] et li [j] renferment en tout 4n−2
éléments. Montrer comment ramener ce nombre à 2n + 2 éléments, tout en conservant la
possibilité de calculer f ∗ et d’afficher tous les postes d’un chemin optimal traversant l’atelier.
15.1.5 Le professeur Nulhardt pense qu’il pourrait y avoir des valeurs ei , ai,j et ti,j pour
lesquelles P LUS - RAPIDE -C HEMIN produirait des valeurs li [j] telles que l1 [j] = 2 et l2 [j] = 1
pour un certain numéro de poste j. En supposant que tous les coûts de transfert ti,j soient
positifs, montrer que le professeur se trompe.
15.2 MULTIPLICATIONS MATRICIELLES ENCHAÎNÉES
Notre exemple suivant de programmation dynamique est un algorithme qui résout le
problème des multiplications matricielles enchaînées. On suppose qu’on a une chaîne
A1 , A2 , . . . , An de n matrices à multiplier, et qu’on souhaite calculer le produit
A1 A2 · · · An .
(15.10)
c Dunod – La photocopie non autorisée est un délit
Il est possible d’évaluer l’expression (15.10) en utilisant, comme sous-programme,
l’algorithme classique pour multiplier les paires de matrices, après avoir placé des parenthèses pour supprimer toute ambiguïté sur l’ordre de multiplication des matrices.
Un produit de matrices entièrement parenthésé est soit une matrice unique, soit le
produit de deux produits matriciels entièrement parenthésés. La multiplication des
matrices est associative, et tous les parenthésages aboutissent donc à une même valeur du produit. Par exemple, si la chaîne de matrices est A1 , A2 , A3 , A4 , le produit
A1 A2 A3 A4 peut être entièrement parenthésé de cinq façons distinctes :
(A1 (A2 (A3 A4 ))) ,
(A1 ((A2 A3 )A4 )) ,
((A1 A2 )(A3 A4 )) ,
((A1 (A2 A3 ))A4 ) ,
(((A1 A2 )A3 )A4 ) .
La manière dont une suite de matrices est parenthésée peut avoir un impact crucial sur
le coût d’évaluation du produit. Commençons par considérer le coût de la multiplication de deux matrices. L’algorithme standard est donné dans le pseudo code suivant.
Les attributs lignes et colonnes représentent le nombre de lignes et de colonnes d’une
matrice.
324
15 • Programmation dynamique
M ULTIPLIER -M ATRICES(A, B)
1 si colonnes[A] fi lignes[B]
2
alors erreur « dimensions incompatibles »
3
sinon pour i ← 1 à lignes[A]
4
faire pour j ← 1 à colonnes[B]
5
faire C[i, j] ← 0
6
pour k ← 1 à colonnes[A]
7
faire C[i, j] ← C[i, j] + A[i, k]·B[k, j]
8
retourner C
On ne peut multiplier deux matrices A et B que si elles sont compatibles : le nombre
de colonnes de A est égal au nombre de lignes de B. Si A est une matrice p × q et B
une matrice q × r, la matrice résultante C est une matrice p × r. Le temps de calcul de
C est dominé par le nombre de multiplications scalaires de la ligne 7, qui vaut pqr.
Dans ce qui suit, nous exprimerons les temps d’exécution en fonction du nombre de
multiplications scalaires.
Pour illustrer les différents coûts induits par différents parenthésages d’un produit de matrices, on considère le problème d’une suite A1 , A2 , A3 de trois matrices. On suppose que les dimensions des matrices sont respectivement 10 × 100,
100 × 5 et 5 × 50. Si l’on multiplie selon le parenthésage ((A1 A2 )A3 ), on effectue
10·100·5 = 5000 multiplications scalaires pour calculer la matrice produit 10 × 5
A1 A2 , plus 10·5·50 = 2500 multiplications scalaires pour multiplier cette matrice
par A3 , pour un total de 7 500 multiplications scalaires. Si on multiplie selon le parenthésage (A1 (A2 A3 )), on effectue 100·5·50 = 25 000 multiplications scalaires pour
calculer la matrice produit 100 × 50 A2 A3 , plus 10·100·50 = 50 000 multiplications
pour multiplier A1 par cette matrice, pour un total de 75 000 multiplications scalaires.
Le calcul du produit selon le premier parenthésage est donc 10 fois plus rapide.
Le problème des multiplications matricielles enchaînées peut être énoncé comme
suit : étant donnée une chaîne A1 , A2 , . . . , An de n matrices où, pour i = 1, 2, . . . , n,
la matrice Ai a la dimension pi−1 × pi , parenthéser entièrement le produit A1 A2 · · · An
de façon à minimiser le nombre de multiplications scalaires.
Notez que, dans le problème des multiplications matricielles enchaînées, on ne
multiplie pas vraiment les matrices. On cherche simplement un ordre de multiplication qui minimise le coût. Généralement, le temps passé à déterminer cet ordre
optimal est plus que compensé par le gain de temps que l’on obtiendra quand on fera
les multiplications matricielles proprement dites (par exemple, quand on fera 7 500
multiplications scalaires au lieu de 75 000).
a) Comptabilisation du nombre de parenthésages
Avant de résoudre le problème des multiplications matricielles enchaînées via la programmation dynamique, vérifions que passer en revue tous les parenthésages possibles ne donne pas un algorithme efficace. Soit P(n) le nombre de parenthésages
15.2
Multiplications matricielles enchaînées
325
possibles d’une séquence de n matrices. Quand n = 1, il n’y a qu’une seule matrice et
donc une seule façon de parenthéser entièrement le produit. Quand n 2, un produit
matriciel entièrement parenthésé est le produit de deux sous-produits matriciels entièrement parenthésés, et la démarcation entre les deux sous-produits peut intervenir
entre les kème et (k + 1)ème matrices, pour tout k = 1, 2, . . . , n − 1. On obtient donc
la récurrence

si n = 1 ,

 1
n−1
(15.11)
P(n) =
P(k)P(n − k) si n 2 .


k=1
Le problème 12.4 vous demandait de montrer que la solution d’une récurrence similaire est la suite des nombres de Catalan qui croît en V(4n /n3/2 ). Un exercice
plus simple (voir exercice 15.2.3) consiste à montrer que la solution de la récurrence (15.11) est V(2n ). Le nombre de solutions est donc exponentiel en n, et la
méthode primitive d’examen exhaustif est, de ce fait, une piètre stratégie pour déterminer le parenthésage optimal de produits matriciels enchaînés.
c Dunod – La photocopie non autorisée est un délit
b) Structure d’un parenthésage optimal
La première étape de la programmation dynamique consiste à trouver la sousstructure optimale, puis à s’en servir pour construire une solution optimale du
problème à partir de solutions optimales de sous-problèmes. Pour le problème des
multiplications matricielles enchaînées, on peut aborder cette étape de la manière
suivante. Par commodité, nous adopterons la notation Ai..j , avec i j, pour la matrice
résultant de l’évaluation du produit Ai Ai+1 · · · Aj . Notez que, si le problème est non
trivial, c’est-à-dire si i < j, alors tout parenthésage optimal du produit Ai Ai+1 · · · Aj
sépare le produit entre Ak et Ak+1 pour un certain k de l’intervalle i k < j. Autrement dit, pour une certaine valeur de k, on commence par calculer les matrices Ai..k
et Ak+1..j , puis on les multiplie ensemble pour obtenir le résultat final Ai..j . Le coût de
ce parenthésage optimal est donc le coût du calcul de la matrice Ai..k , plus celui du
calcul de Ak+1..j , plus celui de la multiplication de ces deux matrices.
La sous-structure optimale de ce problème est la suivante. Supposons qu’un parenthésage optimal de Ai Ai+1 · · · Aj fractionne le produit entre Ak et Ak+1 . Alors, le
parenthésage de la sous-chaîne « préfixe » Ai Ai+1 · · · Ak à l’intérieur de ce parenthésage optimal de Ai Ai+1 · · · Aj est forcément un parenthésage optimal de Ai Ai+1 · · · Ak .
Pourquoi ? S’il existait un parenthésage plus économique de Ai Ai+1 · · · Ak , substituer
ce parenthésage dans le parenthésage optimal de Ai Ai+1 · · · Aj produirait un autre parenthésage de Ai Ai+1 · · · Aj dont le coût serait inférieur à l’optimum : on arrive à une
contradiction. On peut faire la même observation pour le parenthésage de la souschaîne Ak+1 Ak+2 · · · Aj à l’intérieur du parenthésage optimal de Ai Ai+1 · · · Aj : c’est
forcément un parenthésage optimal de Ak+1 Ak+2 · · · Aj .
15 • Programmation dynamique
326
Utilisons maintenant notre sous-structure optimale pour montrer que nous pouvons
construire une solution optimale du problème à partir de solutions optimales de sousproblèmes. Nous avons vu que toute solution d’une instance non triviale du problème
des multiplications matricielles enchaînées nous oblige à fractionner le produit, et que
toute solution optimale contient en elle des solutions optimales d’instances de sousproblème. Par conséquent, on peut construire une solution optimale d’une instance du
problème des multiplications matricielles enchaînées en fractionnant le problème en
deux sous-problèmes (parenthésage optimal de Ai Ai+1 · · · Ak et de Ak+1 Ak+2 · · · Aj ), en
cherchant des solutions optimales d’instances de sous-problème, puis en combinant
ces solutions optimales de sous-problème. Nous devons faire en sorte, quand nous
recherchons l’endroit où fractionner le produit, de considérer tous les emplacements
possibles ; ainsi, nous serons certains d’avoir choisi l’emplacement optimal.
c) Une solution récursive
La deuxième étape de la programmation dynamique consiste à définir récursivement le coût d’une solution optimale en fonction des solutions optimales de sousproblèmes. Pour le problème des multiplications matricielles enchaînées, on prend
comme sous-problèmes les problèmes consistant à déterminer le coût minimal d’un
parenthésage de Ai Ai+1 · · · Aj pour 1 i j n. Soit m[i, j] le nombre minimal de
multiplications scalaires nécessaires pour le calcul de la matrice Ai..j ; pour le problème entier, le coût de la méthode la plus économique pour calculer A1..n sera donc
m[1, n].
On peut définir m[i, j] récursivement de la manière suivante. Si i = j, le problème
est trivial ; la chaîne est constituée d’une seule matrice Ai..i = Ai , et aucune multiplication n’est nécessaire pour calculer le produit. Donc, m[i, i] = 0 pour i = 1, 2, . . . , n.
Pour calculer m[i, j] quand i < j, on exploite la structure d’une solution optimale définie à l’étape 1. Supposons que le parenthésage optimal sépare le produit Ai Ai+1 · · · Aj
entre Ak et Ak+1 , avec i k < j. Alors, m[i, j] est égal au coût minimal du calcul des
sous-produits Ai..k et Ak+1..j , plus le coût de la multiplication de ces deux matrices.
Si l’on se rappelle que chaque matrice Ai est de dimension pi−1 × pi , on voit que
le calcul de la matrice produit Ai..k Ak+1..j demande pi−1 pk pj multiplications scalaires.
On obtient donc
m[i, j] = m[i, k] + m[k + 1, j] + pi−1 pk pj .
Cette équation récursive suppose que l’on connaisse la valeur de k, ce qui
n’est pas le cas. Cela dit, il n’existe que j − i valeurs possibles pour k, à savoir
k = i, i + 1, . . . , j − 1. Comme le parenthésage optimal doit utiliser l’une de ces valeurs pour k, il suffit de toutes les vérifier pour trouver la meilleure. Notre définition
récursive du coût minimal de parenthésage du produit Ai Ai+1 · · · Aj devient
m[i, j] =
0
si i = j ,
min {m[i, k] + m[k + 1, j] + pi−1 pk pj } si i < j .
i k<j
(15.12)
15.2
Multiplications matricielles enchaînées
327
Les valeurs m[i, j] donnent les coûts des solutions optimales des sous-problèmes.
Pour mieux comprendre la construction d’une solution optimale, appelons s[i, j]
une valeur de k à laquelle on peut fractionner le produit Ai Ai+1 · · · Aj pour obtenir
un parenthésage optimal. Autrement dit, s[i, j] est égal à une valeur de k telle que
m[i, j] = m[i, k] + m[k + 1, j] + pi−1 pk pj .
d) Calcul des coûts optimaux
À ce stade, c’est chose simple que d’écrire un algorithme récursif basé sur la récurrence (15.12) pour calculer le coût minimal m[1, n] de la multiplication A1 A2 · · · An .
Toutefois, comme nous le verrons à la section 15.3, cet algorithme prend un temps exponentiel, ce qui n’est pas mieux que la méthode primitive consistant à tester chaque
manière de parenthéser le produit.
L’observation importante que nous pouvons faire à ce stade est que le nombre de
sous-problèmes est assez réduit :!un" problème pour chaque choix de i et j satisfaisant
à 1 i j n, soit au total n2 + n = Q(n2 ). Un algorithme récursif peut rencontrer chaque sous-problème plusieurs fois dans différentes branches de son arbre
de récursivité. Cette propriété de chevauchement des sous-problèmes est le deuxième
grand critère de l’applicabilité de la programmation dynamique (le premier étant la
sous-structure optimale).
c Dunod – La photocopie non autorisée est un délit
Au lieu de calculer la solution de la récurrence (15.12) récursivement, on passe
donc à la troisième étape de la programmation dynamique en calculant le coût optimal via une approche tabulaire ascendante. Le pseudo code suivant suppose que la
matrice Ai est de dimensions pi−1 × pi , pour i = 1, 2, . . . , n. L’entrée est une séquence
p0 , p1 , . . . , pn , où longueur[p] = n + 1. La procédure utilise un tableau auxiliaire
m[1 . . n, 1 . . n] pour mémoriser les coûts m[i, j] et un tableau auxiliaire s[1 . . n, 1 . . n]
qui mémorise quel est l’indice de k qui avait donné le coût optimal lors du calcul de
m[i, j].
Pour implémenter correctement l’approche ascendante (bottom-up), nous devons déterminer quels sont les éléments du tableau qui servent à calculer m[i, j]. L’équation (15.12) montre que le coût m[i, j] du calcul d’un produit enchaîné de j − i + 1
matrices ne dépend que des coûts de calcul de produits enchaînés impliquant moins
de j − i + 1 matrices. Autrement dit, pour k = i, i + 1, . . . , j − 1, la matrice Ai..k est
un produit de k − i + 1 < j − i + 1 matrices et la matrice Ak+1..j est un produit de
j − k < j − i + 1 matrices. L’algorithme doit donc remplir le tableau m d’une façon
qui corresponde à la résolution du problème du parenthésage pour des chaînes de
matrices de longueur croissante.
O RDRE -C HAÎNE - MATRICES(p)
1 n ← longueur[p] − 1
2 pour i ← 1 à n
3
faire m[i, i] ← 0
328
15 • Programmation dynamique
4 pour l ← 2 à n
l est la longueur de la chaîne.
5
faire pour i ← 1 à n − l + 1
6
faire j ← i + l − 1
7
m[i, j] ← ∞
8
pour k ← i à j − 1
9
faire q ← m[i, k] + m[k + 1, j] + pi−1 pk pj
10
si q < m[i, j]
11
alors m[i, j] ← q
12
s[i, j] ← k
13 retourner m et s
L’algorithme commence par l’affectation m[i, i] ← 0, pour i = 1, 2, . . . , n (coûts
minimaux pour les chaînes de longueur 1) aux lignes 2–3. Il utilise ensuite la récurrence (15.12) pour calculer m[i, i+1] pour i = 1, 2, . . . , n−1 (coûts minimaux pour les
chaînes de longueur l = 2) pendant la première exécution de la boucle des lignes 4–
12. Au deuxième passage dans la boucle, il calcule m[i, i + 2] pour i = 1, 2, . . . , n − 2
(coûts minimaux pour les chaînes de longueur l = 3), et ainsi de suite. A chaque
étape, le coût m[i, j] calculé aux lignes 9–12 ne dépend que des éléments de tableau
m[i, k] et m[k + 1, j] déjà calculés.
La figure 15.3 illustre cette procédure pour une suite de n = 6 matrices. Comme
nous n’avons défini m[i, j] que pour i j, seule la partie du tableau m strictement
supérieure à la diagonale principale est utilisée. La figure présente le tableau de façon
à faire apparaître la diagonale principale horizontalement. La chaîne de matrices est
donnée en bas. D’après ce modèle, le coût minimal m[i, j] pour la multiplication
d’une sous-chaîne Ai Ai+1 · · · Aj de matrices peut être trouvé à l’intersection des lignes
partant de Ai vers le Nord-Est, et de Aj vers le Nord-Ouest. Chaque ligne horizontale
du tableau contient les éléments pour les chaînes de matrices de même longueur.
O RDRE -C HAÎNE -M ATRICEScalcule les lignes du bas vers le haut, et de gauche à
droite à l’intérieur de chaque ligne. Un élément m[i, j] est calculé à l’aide des produits
pi−1 pk pj pour k = i, i + 1, . . . , j − 1 et tous les éléments situés au Sud-Ouest et au SudEst de m[i, j].
Un examen simple de la structure de boucles imbriquées de O RDRE -C HAÎNE M ATRICES donne un temps d’exécution O(n3 ) pour l’algorithme. Les boucles sont
imbriquées sur trois niveaux, et chaque indice de boucle (l, i et k) prend au plus n − 1
valeurs. L’exercice 15.2.4 vous demandera de montrer que le temps d’exécution de
cet algorithme est, en fait, aussi V(n3 ). L’algorithme nécessite un espace de stockage
Q(n2 ) pour les tableaux m et s. O RDRE -C HAÎNE -M ATRICES est donc beaucoup plus
efficace que la méthode en temps exponentiel consistant à énumérer tous les parenthésages possibles et à tester chacun d’eux.
e) Construction d’une solution optimale
Bien que O RDRE -C HAÎNE -M ATRICES détermine le nombre optimal de multiplications scalaires nécessaires pour calculer le produit d’une suite de matrices, elle ne
15.2
Multiplications matricielles enchaînées
329
m
s
6
1
15,125
j
5
2
11,875 10,500
4
9,375
3
7,875
1
2
15,750
6
7,125
4,375
2,625
3
5,375
2,500
750
j
i
1,000
3
4
4
3,500
3
3
5
5,000
0
0
0
0
0
0
A1
A2
A3
A4
A5
A6
1
2
1
6
1
3
5
3
3
2
2
3
3
3
3
i
3
4
5
4
5
5
Figure 15.3 Les tableaux m et s calculés par O RDRE -C HAÎNE -M ATRICES pour n = 6 et les
dimensions de matrices suivantes :
matrice
A1
A2
A3
A4
A5
A6
dimension
30 × 35
35 × 15
15 × 5
5 × 10
10 × 20
20 × 25
Les tableaux sont tournés pour présenter horizontalement leur diagonale principale. Seule la diagonale principale et le triangle supérieur sont utilisés dans le tableau m ; pour le tableau s, seul
le triangle supérieur est utilisé. Le nombre minimal de multiplications scalaires nécessaires pour
multiplier les 6 matrices est m[1, 6] = 15 125. Parmi les éléments foncés, les paires ayant le même
ombrage sont regroupées en ligne 9 lors du calcul de
m[2, 2] + m[3, 5] + p p p
m[2, 5] = min m[2, 3] + m[4, 5] + p p p
m[2, 4] + m[5, 5] + p p p
1 2 5
= 0 + 2 500 + 35 · 15 · 20
1 3 5
= 2 625 + 1 000 + 35 · 5 · 20 = 7 125 ,
1 4 5
= 4 375 + 0 + 35 · 10 · 20
= 13 000 ,
= 11 375
c Dunod – La photocopie non autorisée est un délit
= 7125 .
montre pas directement comment multiplier les matrices. Il n’est pas difficile de
construire une solution optimale à partir des données calculées et mémorisées dans
le tableau s[1 . . n, 1 . . n]. Chaque élément s[i, j] contient la valeur de k telle que le
parenthésage optimal de Ai Ai+1 · · · Aj implique un fractionnement du produit entre
Ak et Ak+1 . On sait donc que, pour le calcul optimal de A1..n , la dernière multiplication matricielle sera le produit de A1..s[1,n] et As[1,n]+1..n . Les multiplications antérieures peuvent être calculées récursivement ; en effet, s[1, s[1, n]] détermine la
dernière multiplication effectuée lors du calcul de A1..s[1,n] et s[s[1, n] + 1, n] détermine la dernière multiplication effectuée lors du calcul de As[1,n]+1..n . La procédure récursive ci-après affiche un parenthésage optimal de Ai , Ai+1 , . . . , Aj , à partir
du tableau s calculé par O RDRE -C HAÎNE -M ATRICES et des indices i et j. L’appel
initial A FFICHE -PARENTHÉSAGE -O PTIMAL(s, 1, n) affiche un parenthésage optimal
de A1 , A2 , . . . , An .
15 • Programmation dynamique
330
A FFICHAGE -PARENTHÉSAGE -O PTIMAL(s, i, j)
1 si i = j
2
alors afficher« A »i
3
sinon afficher« ( «
4
A FFICHAGE -PARENTHÉSAGE -O PTIMAL(s, i, s[i, j])
5
A FFICHAGE -PARENTHÉSAGE -O PTIMAL(s, s[i, j] + 1, j)
6
print » ) »
Dans l’exemple de la figure 15.3, l’appel A FFICHAGE -PARENTHÉSAGE -O PTIMAL(s, 1, 6)
affiche le parenthésage ((A1 (A2 A3 ))((A4 A5 )A6 )).
Exercices
15.2.1 Trouver un parenthésage optimal pour le produit d’une suite de matrices dont les
dimensions sont données par la séquence 5, 10, 3, 12, 5, 50, 6.
15.2.2 Donner un algorithme récursif M ULTIPLICATION -C HAÎNE -M ATRICES (A, s, i, j) qui
effectue les multiplications matricielles enchaînées proprement dites, et ce à partir de la
chaîne de matrices A1 , A2 , . . . , An , du tableau s calculé par O RDRE -C HAÎNE -M ATRICES,
et des indices i et j. (L’appel initial sera M ULTIPLICATION -C HAÎNE -M ATRICES (A, s, 1, n).)
15.2.3 Employer la méthode de substitution pour montrer que la solution de la récurrence (15.11) est V(2n ).
15.2.4 Soit R(i, j) le nombre de fois que l’élément de tableau m[i, j] est référencé pendant le
calcul d’autres éléments de tableau dans un appel à O RDRE -C HAÎNE -M ATRICES. Montrer
que le nombre total de références pour le tableau tout entier est
n n
i=1
j=i
R(i, j) =
n3 − n
.
3
(Conseil : L’équation (A.3) pourra vous être utile.)
15.2.5 Montrer qu’un parenthésage entier d’une expression à n éléments contient exactement n − 1 paires de parenthèses.
15.3 ÉLÉMENTS DE LA PROGRAMMATION DYNAMIQUE
Bien que vous ayez déjà travaillé sur deux exemples, vous vous demandez peut-être
dans quelles situations appliquer la programmation dynamique. Dans la perspective
de l’ingénierie, quand faut-il envisager une solution à base de programmation dynamique ? Dans cette section, nous allons examine les deux grandes caractéristiques
que doit posséder un problème d’optimisation pour que la programmation dynamique
15.3
Éléments de la programmation dynamique
331
soit applicable : sous-structure optimale et chevauchement des sous-problèmes. Nous
étudierons également une variante, baptisée recensement (mémoïsation), qui permet
d’exploiter la propriété de chevauchement des sous-problèmes.
a) Sous-structure optimale
La première étape de la résolution d’un problème d’optimisation via programmation
dynamique est de caractériser la structure d’une solution optimale. Retenons qu’un
problème exhibe une sous-structure optimale si une solution optimale au problème
contient en elle des solutions optimales de sous-problèmes. Chaque fois qu’un problème exhibe une sous-structure optimale, c’est un bon indice de l’utilisabilité de la
programmation dynamique. (Cela peut aussi signifier qu’une stratégie gloutonne est
applicable. Voir chapitre 16.) Avec la programmation dynamique, on construit une
solution optimale du problème à partir de solutions optimales de sous-problèmes.
Par conséquent, on doit penser à vérifier que la gamme des sous-problèmes que l’on
considère inclut les sous-problèmes utilisés dans une solution optimale.
Nous avons découvert la sous-structure optimale dans les deux problèmes étudiés
jusqu’ici dans ce chapitre. À la section 15.1, nous avons observé que le chemin optimal passant par le poste j de l’une ou l’autre des chaînes de montage contenait le
chemin optimal passant par le poste j − 1 d’une des deux chaînes. À la section 15.2,
nous avons observé qu’un parenthésage optimal de Ai Ai+1 · · · Aj qui fractionne le produit de Ak et Ak+1 contient des solutions optimales pour les problèmes consistant à
parenthéser Ai Ai+1 · · · Ak et Ak+1 Ak+2 · · · Aj .
La découverte de la sous-structure optimale obéit au schéma général suivant :
c Dunod – La photocopie non autorisée est un délit
1) Vous montrez qu’une solution du problème consiste à faire un choix, par exemple
à choisir un poste précédant dans une chaîne de montage ou un emplacement de
fractionnement dans une chaîne de produits de matrices. Ayant fait ce choix, vous
êtes ramené à résoudre un ou plusieurs sous-problèmes.
2) Vous supposez que, pour un problème donné, on vous donne le choix qui conduit
à une solution optimale. Pour l’instant, vous ne vous souciez pas de la façon dont
on détermine ce choix. Vous faites comme si on vous le donnait tout cuit.
3) À partir de ce choix, vous déterminez quels sont les sous-problèmes qui en découlent et comment caractériser au mieux l’espace des sous-problèmes résultant.
4) Vous montrez que les solutions des sous-problèmes employées par la solution optimale du problème doivent elles-mêmes être optimales, et ce en utilisant la technique du « couper-coller » : vous supposez que chacune des solutions de sousproblème n’est pas optimale et vous en déduisez une contradiction. En particulier,
en « coupant » une solution de sous-problème non optimale et en la « collant »
dans la solution optimale, vous montrez que vous obtenez une meilleure solution pour le problème initial, ce qui contredît l’hypothèse que vous avez déjà
une solution optimale. S’il y a plusieurs sous-problèmes, ils sont généralement
332
15 • Programmation dynamique
similaires, de sorte que l’argument couper-coller utilisé pour l’un peut resservir
pour les autres, moyennant une petite adaptation.
Pour caractériser l’espace des sous-problèmes, une règle empirique consiste à essayer de garder l’espace aussi simple que possible puis à l’étendre en fonction des
besoins. Ainsi, l’espace des sous-problèmes que nous avons considéré pour l’ordonnancement des chaînes de montage était le chemin optimal entre l’entrée de l’atelier
et les postes S1,j et S2,j . Cet espace de sous-problèmes fonctionnait bien, et il n’était
point besoin d’essayer un espace plus général de sous-problèmes.
Inversement, supposons que nous ayons essayé de limiter notre espace de sousproblèmes pour la multiplication de matrices en chaîne à des produits matriciels de la
forme A1 A2 · · · Aj . Comme précédemment, un parenthésage optimal doit fractionner
ce produit entre Ak et Ak+1 pour un certain 1 k j. À moins que nous puissions
garantir que k est toujours égal à j − 1, nous trouverons que nous avons des sousproblèmes de la forme A1 A2 · · · Ak et Ak+1 Ak+2 · · · Aj , et que ce dernier sous-problème
n’est pas de la forme A1 A2 · · · Aj . Pour ce problème, il fallait permettre aux sousproblèmes de varier « aux deux bouts », c’est-à-dire permettre à i et à j de varier tous
les deux dans le sous-problème Ai Ai+1 · · · Aj .
La sous-structure optimale varie d’un domaine de problème à l’autre de deux façons :
1) le nombre de sous-problèmes qui sont utilisés dans une solution optimale du problème originel, et
2) le nombre de choix que l’on a pour déterminer le(s) sous-problème(s) à utiliser
dans une solution optimale.
Dans l’ordonnancement de chaîne de montage, une solution optimale n’utilise qu’un
seul sous-problème, mais il faut considérer deux choix pour déterminer une solution
optimale. Pour trouver le chemin optimal passant par le poste Si,j , on utilise soit le
chemin optimal passant par S1,j−1 soit le chemin optimal passant par S2,j−1 ; quel que
soit le choix que nous ferons, il représentera le sous-problème à résoudre de manière
optimale. La multiplication enchaînée de matrices pour la sous-chaîne Ai Ai+1 · · · Aj
est un exemple à deux sous-problèmes et j − i choix. Pour une matrice donnée Ak au
niveau de laquelle on fractionne le produit, on a deux sous-problèmes (parenthéser
Ai Ai+1 · · · Ak et parenthéser Ak+1 Ak+2 · · · Aj ) et l’on doit résoudre les deux de manière
optimale. Après avoir déterminé les solutions optimales des sous-problèmes, on choisit parmi j − i candidats pour l’indice k.
De manière informelle, le temps d’exécution d’un algorithme de programmation
dynamique dépend du produit de deux facteurs : le nombre de sous-problèmes et
le nombre de choix envisagés pour chaque sous-problème. Dans l’ordonnancement
des chaînes de montage, on avait Q(n) sous-problèmes et seulement deux choix possibles pour chaque sous-problème, ce qui donnait un temps d’exécution Q(n). Pour la
multiplication de matrices en chaîne, il y avait Q(n2 ) sous-problèmes et chaque sousproblème proposait au plus n − 1 choix, ce qui donnait un temps d’exécution O(n3 ).
15.3
Éléments de la programmation dynamique
333
La programmation dynamique utilise la sous-structure optimale de manière ascendante (bottom-up) : on commence par trouver des solution optimales pour les sousproblèmes, après quoi l’on trouve une solution optimale pour le problème. Trouver
une solution optimale pour le problème implique de faire un choix entre des sousproblèmes : lesquels prendre pour résoudre le problème ? Le coût de la solution du
problème est généralement le cumul des coûts des sous-problèmes, plus un coût lié
directement aux choix lui-même. Dans l’ordonnancement des chaînes de montage,
par exemple, on commençait par résoudre les sous-problèmes consistant à trouver le
chemin optimal passant par les postes S1,j−1 et S2,j−1 ; ensuite, on choisissait l’un de
ces postes comme poste précédant le poste Si,j . Le coût attribuable au choix lui-même
dépendait de ce que l’on changeait ou non de chaîne de montage entre les postes j − 1
et j ; ce coût était ai,j si l’on restait sur la même chaîne et ti ,j−1 + ai,j , avec i fi i,
si l’on changeait de chaîne. Dans la multiplication des matrices, on déterminait des
parenthésages optimaux pour les sous-chaînes de Ai Ai+1 · · · Aj , puis l’on choisissait
la matrice au niveau de laquelle il fallait fractionner le produit. Le coût attribuable au
choix lui-même était donné par le terme pi−1 pk pj .
Au chapitre 16, nous étudierons les « algorithmes gloutons », qui présentent moult
similitudes avec la programmation dynamique. En particulier, les problèmes auxquels s’appliquent des algorithmes gloutons ont une sous-structure optimale. Une
grande différence entre algorithmes gloutons et programmation dynamique est que,
avec les algorithmes gloutons, on utilise la sous-structure optimale d’une manière
descendante (top-down). Au lieu de commencer par trouver des solutions optimales
pour les sous-problèmes puis de choisir un sous-problème, un algorithme glouton
commence par faire un choix (celui qui semble être le meilleur sur le moment) puis
résout le sous-problème résultant.
➤ Subtilités
c Dunod – La photocopie non autorisée est un délit
Il ne faut pas supposer qu’une sous-structure optimale est applicable quand ce n’est
pas le cas. Considérons les deux problèmes suivants, dans lesquels on a un graphe
orienté G = (V, E) et des sommets u, v ∈ V.
Plus court chemin non pondéré (1) : Trouver un chemin entre u et v qui soit composé d’un minimum d’arcs. Un tel chemin doit être élémentaire, car le fait de
supprimer un circuit d’un chemin produit un circuit ayant moins d’arcs.
Plus long chemin élémentaire non pondéré : Trouver un chemin élémentaire de u
à v qui soit composé du plus grand nombre d’arcs possible. Il faut ajouter la
contrainte de chemin élémentaire, car autrement on peut traverser un circuit autant
de fois que l’on veut pour créer un chemin ayant un nombre d’arcs arbitraire.
(1) Nous employons le terme « non pondéré » pour distinguer ce problème de celui consistant à trouver des
plus courts chemins composés d’arcs pondérés, que nous verrons aux chapitres 24 et 25 La technique de
recherche en largeur, traitée au chapitre 22, permet de résoudre le problème non pondéré.
15 • Programmation dynamique
334
Le problème du plus court chemin non pondéré exhibe une sous-structure optimale, comme nous allons le voir. Supposons que u fi v, afin que le problème soit
non trivial. Alors, tout chemin p de u à v contient un sommet intermédiaire, par
exemple w. (Notez que w peut être u ou v.) Donc, on peut décomposer le chemin
p
p1
p2
u v en sous-chemins u w v. Visiblement, le nombre d’arcs de p est la
somme du nombre d’arcs de p1 et du nombre d’arcs de p2 . Nous affirmons que, si
p est un chemin optimal (c’est-à-dire, un plus court chemin) de u à v, alors p1 est
un plus court chemin de u à w. Pourquoi ? Nous utilisons un raisonnement basé sur
la technique du « couper-coller » : s’il y avait un autre chemin, disons p1 , de u à w
ayant moins d’arcs que p1 , alors on pourrait couper p1 et coller p1 pour produire un
p
p2
chemin u 1 w v ayant moins d’arcs que p, ce qui contredirait l’optimalité de p.
De manière symétrique, p2 est un plus court chemin de w à v. Donc, on peut trouver
un plus court chemin de u à v en considérant tous les sommets intermédiaires w, en
trouvant un plus court chemin de u à w et un plus court chemin de w à v, puis en
choisissant un sommet intermédiaire w qui donne le plus court chemin globalement.
À la section 25.2, nous emploierons une variante de cette observation de la sousstructure optimale pour trouver un plus court chemin entre chaque paire de sommets
d’un graphe orienté pondéré.
Il est tentant de penser que le problème consistant à trouver un plus long chemin élémentaire non pondéré exhibe une sous-structure optimale, lui aussi. Après
p
tout, si l’on décompose un plus long chemin élémentaire u v en sous-chemins
p1
p2
u w v, alors p1 n’est-il pas un plus long chemin élémentaire de u à w et p2
n’est-il pas un plus long chemin élémentaire de w à v ? La réponse est non ! La figure 15.4 donne un exemple. Considérons le chemin q → r → t, qui est un plus long
chemin élémentaire de q à t. Est-ce que q → r est un plus long chemin élémentaire
de q à r ? Non, car le chemin q → s → t → r est un chemin élémentaire qui est
plus long. Est-ce que r → t est un plus long chemin élémentaire de r à t ? Non, car le
chemin r → q → s → t est un chemin élémentaire qui est plus long.
q
r
s
t
Figure 15.4 Graphe orienté montrant que le problème qui consiste à trouver un plus long chemin
élémentaire dans un graphe orienté non pondéré n’a pas de sous-structure optimale. Le chemin
q → r → t est un plus long chemin élémentaire de q à t, mais le sous-chemin q → r n’est pas
un plus long chemin élémentaire de q à r, pas plus que le sous-chemin r → t n’est un plus long
chemin élémentaire de r à t.
Cet exemple montre que, pour les plus longs chemins élémentaires, non seulement
il manque une sous-structure optimale, mais en plus on ne peut pas toujours construire
une solution « licite » à partir de solutions de sous-problèmes. Si l’on combine les
plus longs chemins élémentaires q → s → t → r et r → q → s → t, on obtient le
15.3
Éléments de la programmation dynamique
335
chemin q → s → t → r → q → s → t qui n’est pas élémentaire. En fait, le problème
consistant à trouver un plus long chemin élémentaire non pondéré ne semble pas avoir
de sous-structure optimale de quelque sorte que ce soit. Aucun algorithme efficace de
programmation dynamique n’a pu être trouvé pour ce problème. En fait, ce problème
est NP-complet (voir chapitre 34), ce qui implique qu’il est peu probable qu’il puisse
être résolu en temps polynomial.
c Dunod – La photocopie non autorisée est un délit
Qu’est-ce qui fait que la sous-structure d’un plus long chemin élémentaire est si
différente de celle d’un plus court chemin ? Deux sous-problèmes sont utilisés dans
une solution du problème des plus longs ou des plus courts chemins, mais les sousproblèmes du problème du plus long chemin élémentaire ne sont pas indépendants
alors que ceux du problème du plus court chemin le sont. Qu’entend-on par sousproblèmes indépendants ? On entend que la solution d’un sous-problème n’affecte
pas la solution d’un autre sous-problème du même problème. Dans l’exemple de la
figure 15.4, le problème consiste à trouver un plus long chemin élémentaire de q à t
ayant deux sous-problèmes : trouver des plus longs chemins élémentaires de q à r et
de r à t. Pour le premier de ces sous-problèmes, on choisit le chemin q → s → t → r,
et donc on a utilisé les sommets s et t. On ne peut plus utiliser ces sommets dans le
second sous-problème, vu que la combinaison des deux solutions de sous-problèmes
donnerait un chemin qui n’est pas élémentaire. Si l’on ne peut pas utiliser le sommet
t dans le second problème, alors on ne peut pas résoudre du tout, car il faut que t soit
sur le chemin que nous trouvons et ce n’est pas le sommet au niveau duquel nous
« recollons » les solutions des sous-problème (ce sommet est r). Le fait que nous
utilisions les sommets s et t dans une solution de sous-problème nous empêche de les
utiliser dans l’autre solution de sous-problème. Or, nous devons utiliser au moins l’un
des deux pour résoudre l’autre sous-problème, et nous devons utiliser les deux pour
le résoudre de manière optimale. Donc, nous disons que ces sous-problèmes ne sont
pas indépendants. Vu d’une autre façon, l’utilisation de ressources pour la résolution
d’un sous-problème (ces ressources étant des sommets) les rend indisponibles pour
l’autre sous-problème.
Qu’est-ce qui fait, alors, que les sous-problèmes sont indépendants dans le problème du plus court chemin ? La réponse est que, par nature, les sous-problèmes n’ont
pas de ressources en commun. Nous affirmons que, si un sommet w est sur un plus
p1
court chemin p de u à v, alors on peut coller n’importe quel plus court chemin u w
p2
avec n’importe quel plus court chemin w v pour produire un plus court chemin de
u à v. Nous sommes assurés que, à part w, aucun sommet ne peut apparaître à la fois
sur les deux chemins p1 et p2 . Pourquoi ? Supposez qu’un sommet x fi w appartienne
pux
pxv
à la fois à p1 et p2 ; on peut alors décomposer p1 en u x w et p2 en w x v.
En vertu de la sous-structure optimale de ce problème, le chemin p a autant d’arcs que
pux
pxv
p1 et p2 réunis ; disons que p a e arcs. Maintenant, construisons un chemin u x v
de u à v. Ce chemin a au plus e − 2 arcs, ce qui contredit l’hypothèse que p est un plus
court chemin. Donc, nous avons la certitude que les sous-problèmes du problème du
plus court chemin sont indépendants. Les deux problèmes traités aux sections 15.1
336
15 • Programmation dynamique
et 15.2 ont des sous-problèmes indépendants. Dans la multiplication matricielle en
chaîne, les sous-problèmes consistent à multiplier les sous-chaînes Ai Ai+1 · · · Ak et
Ak+1 Ak+2 · · · Aj . Ces sous-chaînes sont disjointes, de sorte qu’aucune matrice ne peut
être dans les deux. Dans l’ordonnancement de chaîne de montage, pour déterminer
le chemin optimal passant par le poste Si,j , on regarde les chemins optimaux passant
par les postes S1,j−1 et S2,j−1 . Comme notre solution pour le chemin optimal passant
par le poste Si,j inclut une seule des solutions de sous-problème, ce sous-problème
est automatiquement indépendant de tous les autres sous-problèmes employés dans
la solution.
b) Chevauchement des sous-problèmes
La seconde caractéristique que doit avoir un problème d’optimisation pour que la
programmation dynamique lui soit applicable est la suivante : l’espace des sousproblèmes doit être « réduit », au sens où un algorithme récursif pour le problème
résout constamment les mêmes sous-problèmes au lieu d’en engendrer toujours de
nouveaux. En général, le nombre total de sous-problèmes distincts est polynomial
par rapport à la taille de l’entrée. Quand un algorithme récursif repasse sur le même
problème constamment, on dit que le problème d’optimisation contient des sousproblèmes qui se chevauchent. A contrario, un problème pour lequel l’approche
diviser-pour-régner est plus adaptée génère, le plus souvent, des problèmes nouveaux
à chaque étape de la récursivité. Les algorithmes de programmation dynamique tirent
parti du chevauchement des sous-problèmes en résolvant chaque sous-problème une
fois, puis en stockant la solution dans un tableau, ce qui permettra ultérieurement de
retrouver la solution avec un temps de recherche constant.
À la section 15.1, nous avons vu brièvement comment une solution récursive de
l’ordonnancement de chaîne de montage fait 2n−j références à fi [j], avec
j = 1, 2, . . . , n. Notre solution tabulaire permet de réduire un algorithme récursif à
temps exponentiel à un temps linéaire.
Pour illustrer de manière plus précise la propriété de chevauchement des sousproblèmes, réexaminons le problème de la multiplication de matrices en chaîne. Sur
la figure 15.3, on remarque que O RDRE -C HAÎNE -M ATRICES recherche itérativement
la solution de sous-problèmes situés sur des lignes plus basses quand elle résout les
sous-problèmes situés sur des lignes plus hautes. Par exemple, l’élément m[3, 4] est
référencé 4 fois : pendant les calculs de m[2, 4], m[1, 4], m[3, 5] et m[3, 6]. Si m[3, 4]
était recalculé chaque fois au lieu d’être lu dans une table, le temps d’exécution augmenterait très rapidement. Pour comprendre cela, considérons la procédure récursive
(inefficace) ci-après, qui détermine m[i, j], nombre minimal de multiplications scalaires nécessaires pour calculer le produit d’une suite de matrices Ai..j = Ai Ai+1 · · · Aj .
La procédure est directement basée sur la récurrence (15.12).
C HAÎNE -M ATRICES -R ÉCURSIF(p, i, j)
1 si i = j
2
alors retourner 0
3 m[i, j] ← ∞
15.3
Éléments de la programmation dynamique
4
5
6
7
8
337
pour k ← i à j − 1
faire q ← C HAÎNE -M ATRICES -R ÉCURSIF(p, i, k)
+ C HAÎNE -M ATRICES -R ÉCURSIF(p, k + 1, j)
+ pi−1 pk pj
si q < m[i, j]
alors m[i, j] ← q
retourner m[i, j]
La figure 15.5 montre l’arborescence récursive correspondant à C HAÎNE -M ATRICES R ÉCURSIF (p, 1, 4). Chaque nœud est étiqueté par les valeurs des paramètres i et j.
Notez que certaines paires de valeurs apparaissent plusieurs fois.
1..4
1..1
2..4
1..2
2..2
3..4
2..3
4..4
3..3
4..4
2..2
3..3
1..1
3..4
2..2
3..3
1..3
4..4
4..4
1..1
2..3
1..2
3..3
2..2
3..3
1..1
2..2
c Dunod – La photocopie non autorisée est un délit
Figure 15.5 Arborescence récursive correspondant au calcul de C HAÎNE -M ATRICES -R ÉCURSIF
(p, 1, 4). Chaque nœud contient les paramètres i et j. La procédure M ÉMORISATION -C HAÎNE M ATRICES (p, 1, 4) remplace par une lecture dans un tableau les calculs qui sont effectués dans
un sous-arbre sur fond gris.
En fait, on peut montrer que le temps d’exécution requis par cette procédure récursive pour calculer m[1, n] est au moins exponentiel en n. Soit T(n) le temps mis par
C HAÎNE -M ATRICES -R ÉCURSIF pour calculer un parenthésage optimal d’une chaîne
de n matrices. Si l’on suppose que l’exécution des lignes 1–2 et des lignes 6–7 prend
chacune au moins une unité de temps, l’analyse de la procédure conduit à la récurrence
T(1) 1 ,
n−1
(T(k) + T(n − k) + 1)
pour n > 1 .
T(n) 1 +
k=1
En remarquant que, pour i = 1, 2, . . . , n − 1, chaque terme T(i) apparaît une fois sous
la forme T(k) et une fois sous la forme T(n − k), puis en regroupant les n − 1 termes 1
de la sommation avec le 1 extérieur, on peut réécrire la récurrence sous la forme :
T(n) 2
n−1
i=1
T(i) + n .
(15.13)
15 • Programmation dynamique
338
Nous allons prouver que T(n) = V(2n ) à l’aide de la méthode de substitution. Plus
précisément, nous allons montrer que T(n) 2n−1 pour tout n 1. La base est
simple à établir, puisque T(1) 1 = 20 . Par récurrence, pour n 2, on a
T(n) 2
=
2
n−1
i=1
n−2
2i−1 + n
2i + n
i=0
n−1
− 1) + n
= 2(2
n
= (2 − 2) + n
2n−1 ,
ce qui achève la démonstration. Donc, la quantité totale de travail effectué par l’appel
C HAÎNE -M ATRICES -R ÉCURSIF(p, 1, n) est au moins exponentielle en n.
Comparons cet algorithme récursif descendant (top-down) à l’algorithme de programmation dynamique ascendant. Ce dernier est plus efficace parce qu’il tire parti
de la propriété de superposition des sous-problèmes. Il n’existe que Q(n2 ) sousproblèmes différents, et l’algorithme de programmation dynamique résout chaque
sous-problème une seule fois. En revanche, l’algorithme récursif recommence la résolution d’un sous-problème chaque fois que celui-ci réapparaît dans l’arborescence
récursive. Chaque fois qu’une arborescence récursive traduisant la solution récursive
naïve d’un problème contient régulièrement le même sous-problème et que le nombre
total des sous-problèmes est petit, c’est une bonne idée que de penser à une résolution
via programmation dynamique.
c) Reconstruction d’une solution optimale
Concrètement, on stocke souvent dans un tableau le choix que l’on a fait pour chaque
sous-problème ; cela nous dispense de reconstruire cette donnée à partir des coûts que
l’on a stockés. Dans l’ordonnancement de chaîne de montage, on stockait dans li [j]
le poste précédant Si,j dans un chemin optimal passant par Si,j . Une autre technique
serait la suivante : après remplissage du tableau fi [j] tout entier, on détermine quel est
le poste qui précède S1,j dans un chemin optimal passant par Si,j , et ce avec un peu de
travail supplémentaire. Si f1 [j] = f1 [j − 1] + a1,j , alors le poste S1,j−1 précède S1,j dans
un chemin optimal passant par S1,j . Autrement, c’est que f1 [j] = f2 [j − 1] + t2,j−1 + a1,j
et donc que S2,j−1 précède S1,j . Pour l’ordonnancement de chaîne de montage, reconstruire les postes prédécesseur prend seulement un temps O(1) par poste, même
sans le tableau li [j].
Pour le produit de matrices en chaîne, en revanche, le tableau s[i, j] nous épargne
un travail significatif quand on reconstruit une solution optimale. Supposons que l’on
ne gère pas de tableau s[i, j] et que l’on se contente de gérer le tableau m[i, j] contenant les coûts des sous-problèmes optimaux. Il y a j − i choix pour déterminer quels
sont les sous-problèmes à utiliser dans une solution optimale de parenthésage de
15.3
Éléments de la programmation dynamique
339
Ai Ai+1 · · · Aj , et j−i n’est pas une constante. Donc, il faudrait un temps Q(j−i) = v(1)
pour reconstruire les sous-problèmes que nous avons choisis pour une solution à
un problème donné. En stockant dans s[i, j] l’indice de la matrice au niveau de laquelle on fractionne le produit Ai Ai+1 · · · Aj , on peut reconstruire chaque choix en
temps O(1).
d) Recensement
Il existe une variante de la programmation dynamique qui offre souvent la même efficacité que l’approche usuelle, tout en maintenant une stratégie descendante. Le principe est de recenser les actions naturelles, mais inefficaces, de l’algorithme récursif.
Comme avec la programmation dynamique ordinaire, on conserve dans un tableau
les solutions des sous-problèmes, mais la structure de contrôle pour le remplissage
du tableau est plus proche de l’algorithme récursif.
Un algorithme récursif de recensement gère un élément du tableau pour la solution
de chaque sous-problème. Chaque élément contient au départ une valeur spéciale,
pour indiquer qu’il n’a pas encore été rempli. Lorsque le sous-problème est rencontré pour la première fois durant l’exécution de l’algorithme récursif, sa solution est
calculée puis stockée dans le tableau. A chaque réapparition du sous-problème, la
valeur stockée dans le tableau est tout simplement lue et retournée au programme
principal(2) .
La procédure suivante est une variante de C HAÎNE -M ATRICES -R ÉCURSIF avec recensement.
c Dunod – La photocopie non autorisée est un délit
M ÉMORISATION -C HAÎNE -M ATRICES(p)
1 n ← longueur[p] − 1
2 pour i ← 1 à n
3
faire pour j ← i à n
4
faire m[i, j] ← ∞
5 retourner R ÉCUPÉRER -C HAÎNE(p, 1, n)
R ÉCUPÉRER -C HAÎNE(p, i, j)
1 si m[i, j] < ∞
2
alors retourner m[i, j]
3 si i = j
4
alors m[i, j] ← 0
5
sinon pour k ← i à j − 1
6
faire q ← R ÉCUPÉRER -C HAÎNE(p, i, k)
+ R ÉCUPÉRER -C HAÎNE(p, k + 1, j) + pi−1 pk pj
7
si q < m[i, j]
8
alors m[i, j] ← q
9 retourner m[i, j]
(2) Cette approche présuppose que l’ensemble de tous les paramètres possibles de sous-problème est connu,
et que la relation entre positions dans le tableau et sous-problèmes est établie. Une autre approche consiste à
recenser via hachage, en se servant des paramètres de sous-problème comme clés.
340
15 • Programmation dynamique
M ÉMORISATION -C HAÎNE -M ATRICES, à l’instar de O RDRE -C HAÎNE -M ATRICES,
gère un tableau m[1 . . n, 1 . . n] de valeurs calculées de m[i, j], nombre minimal de
multiplications scalaires nécessaires pour calculer la matrice Ai..j . Chaque élément du
tableau contient initialement la valeur ∞ pour indiquer que l’élément n’a pas encore
été rempli. Lorsque l’appel R ÉCUPÉRER -C HAÎNE(p, i, j) est exécuté, si m[i, j] < ∞ à
la ligne 1, la procédure se contente de retourner le coût m[i, j] calculé précédemment
(ligne 2). Sinon, le coût est calculé comme dans C HAÎNE -M ATRICES -R ÉCURSIF,
stocké dans m[i, j] puis retourné au programme principal. (La valeur ∞ est pratique pour désigner un élément non rempli, puisque c’est la valeur utilisée pour initialiser m[i, j] en ligne 3 de C HAÎNE -M ATRICES -R ÉCURSIF.) Donc, R ÉCUPÉRER C HAÎNE(p, i, j) retourne toujours la valeur de m[i, j] mais ne la calcule que si c’est la
première fois que R ÉCUPÉRER -C HAÎNE est appelée avec les paramètres i et j.
Sur la figure 15.5, on peut voir l’économie de temps réalisée quand on utilise
M ÉMORISATION -C HAÎNE -M ATRICES au lieu de C HAÎNE -M ATRICES -R ÉCURSIF.
Les arbres sur fond gris représentent les valeurs qui sont relues au lieu d’être calculées.
À l’instar de l’algorithme de programmation dynamique O RDRE -C HAÎNE -M A TRICES , la procédure M ÉMORISATION -C HAÎNE -M ATRICES s’exécute en temps O(n3 ).
Chacun des Q(n2 ) éléments du tableau est initialisé une fois à la ligne 4 de M ÉMORISA TION -C HAÎNE -M ATRICES . On peut classer les appels à R ÉCUPÉRER -C HAÎNE en
deux types :
1) appels où m[i, j] = ∞, de sorte que les lignes 3–9 sont exécutées, et
2) appels où m[i, j] < ∞, de sorte que R ÉCUPÉRER -C HAÎNE se contente de rendre
la main en ligne 2.
Il y a Q(n2 ) appels du premier type, un par élément de tableau. Tous les appels du
second type sont effectués, en tant qu’appels récursifs, par des appels du premier type.
Chaque fois qu’un appel de R ÉCUPÉRER -C HAÎNE fait des appels récursifs, il en fait
O(n). Donc, il y a en tout O(n3 ) appels du second type. Chaque appel du second type
prend un temps O(1), et chaque appel du premier type prend un temps O(n), plus le
temps passé dans ses appels récursifs. Le temps total est donc O(n3 ). Le recensement
ramène donc un algorithme à temps V(2n ) à un algorithme à temps O(n3 ).
En résumé : le problème de la multiplication de matrices en chaîne peut être résolu en temps O(n3 ) soit par un algorithme descendant à recensement, soit par un
algorithme ascendant de programmation dynamique. Les deux méthodes tirent parti
de la propriété de chevauchement des sous-problèmes. Il n’existe au total que Q(n2 )
sous-problèmes différents, et chacune de ces deux méthodes ne calcule la solution
pour chaque sous-problème qu’une seule fois. Sans recensement, l’algorithme récursif basique s’exécute en temps exponentiel, car les sous-problèmes sont résolus de
manière répétée.
En général, si tous les sous-problèmes doivent être résolus au moins une fois, normalement un algorithme ascendant de programmation dynamique surpasse d’un facteur constant un algorithme descendant à recensement, car il élimine la surcharge in-
15.4
Plus longue sous-séquence commune
341
duite par les appels récursifs et diminue la charge de gestion du tableau. Par ailleurs,
il existe des problèmes pour lesquels le modèle régulier des accès au tableau, tel que
mis en œuvre dans l’algorithme de programmation dynamique, permet de réduire encore plus les besoins en temps et en espace. En revanche, si certains sous-problèmes
de l’espace des sous-problèmes n’ont pas besoin d’être résolus du tout, la solution
du recensement présente l’avantage de ne résoudre que les sous-problèmes qui sont
vraiment nécessaires.
Exercices
15.3.1 Quelle est la manière la plus efficace de déterminer le nombre optimal de multiplications dans un problème de multiplications matricielles en chaîne : énumérer tous les parenthésages possibles dans le produit puis calculer le nombre de multiplications pour chacun, ou
bien exécuter C HAÎNE -M ATRICES -R ÉCURSIF ? Justifier la réponse
15.3.2 Dessiner l’arborescence récursive de la procédure T RI -F USION, vue à la section 2.3.1,
sur un tableau de 16 éléments. Expliquer pourquoi le recensement ne permet pas d’améliorer
la vitesse d’un bon algorithme diviser-pour-régner comme T RI -F USION
15.3.3 Soit une variante du problème des multiplications matricielles en chaîne, dans laquelle on cherche à parenthéser la chaîne de matrices de façon à maximiser, et non à minimiser, le nombre de multiplications scalaires. Ce problème exhibe-t-il une sous-structure
optimale ?
c Dunod – La photocopie non autorisée est un délit
15.3.4 Décrire le chevauchement des sous-problèmes dans l’ordonnancement de chaîne de
montage.
15.3.5 Nous avons dit qu’avec la programmation dynamique on commence par résoudre les
sous-problèmes, après quoi on choisit ceux que l’on va utiliser dans une solution optimale du
problème global. Le professeur Folamour affirme qu’il n’est pas toujours indispensable de
résoudre tous les sous-problèmes pour trouver une solution optimale. Elle dit que l’on peut
trouver une solution optimale pour le problème des produits matriciels en chaîne en choisissant toujours la matrice Ak au niveau de laquelle on fractionne le sous-produit Ai Ai+1 · · · Aj
(en sélectionnant k de façon qu’il minimise la quantité pi−1 pk pj ) avant de résoudre les sousproblèmes. Trouver une instance du problème des multiplications matricielles en chaîne pour
laquelle cette approche gloutonne donne une solution sous-optimale.
15.4 PLUS LONGUE SOUS-SÉQUENCE COMMUNE
En biologie, on doit souvent comparer l’ADN de deux (ou plusieurs) organismes.
Un échantillon d’ADN est une suite de molécules appelées bases, les bases possibles
étant l’adénine, la guanine, la cytosine et la thymine. Si l’on représente chacune des
ces bases par leurs initiales, un échantillon d’ADN s’exprime sous la forme d’une
342
15 • Programmation dynamique
chaîne prise sur l’ensemble fini {A, C, G, T}. (Voir annexe C pour la définition d’une
chaîne.) Ainsi, l’ADN d’un organisme sera S1 = ACCGGTCGAGTGCGCGGAAGCCGGCCGAA
alors que l’ADN d’un autre sera S2 = GTCGTTCGGAATGCCGTTGCTCTGTAAA. Comparer deux échantillons d’ADN permet, entre autres, de déterminer leur degré de
« similitude », qui mesure en quelque sorte la façon dont les deux organismes sont
apparentés. La similitude peut se définir de bien des façons différentes. Par exemple,
on peut dire que deux échantillons d’ADN sont semblables si l’un est une sous-chaîne
de l’autre. (Le chapitre 32 présente des algorithmes relatifs à cette problématique.)
Dans notre exemple, ni S1 ni S2 n’est une sous-chaîne de l’autre. Autre possibilité :
on pourrait dire que deux échantillons sont semblables si le nombre de modifications
nécessaires pour transformer l’un en l’autre est faible. (Le problème 15.3 se penchera
sur cette notion.) Un autre moyen de mesurer la similitude des échantillons S1 et S2
est de trouver un troisième échantillon S3 tel que les bases de S3 apparaissent dans
S1 et dans S2 ; ces bases doivent apparaître dans le même ordre, mais pas forcément
de manière consécutive. Plus l’échantillon S3 trouvé est long, plus S1 et S2 sont semblables. Dans notre exemple, le plus long S3 est GTCGTCGGAAGCCGGCCGAA.
Nous formaliserons cette dernière notion de similitude sous le nom de problème de
la plus longue sous-séquence commune. Une sous-séquence d’une séquence donnée
est tout simplement constituée de la séquence de départ, à laquelle on a retiré certains éléments (éventuellement zéro). Plus formellement, étant donnée une séquence
X = x1 , x2 , . . . , xm , une séquence Z = z1 , z2 , . . . , zk est une sous-séquence de X s’il
existe une séquence i1 , i2 , . . . , ik strictement croissante d’indices de X tels que, pour
tout j = 1, 2, . . . , k, on ait xij = zj . Par exemple, Z = B, C, D, B est une sous-séquence
de X = A, B, C, B, D, A, B, correspondant à la séquence d’indices 2, 3, 5, 7.
Etant données deux séquences X et Y, on dit qu’une séquence Z est une sousséquence commune de X et Y si Z est une sous-séquence de X et de Y. Par exemple,
si X = A, B, C, B, D, A, B et Y = B, D, C, A, B, A, la séquence B, C, A est une
sous-séquence commune de X et de Y. Toutefois, la séquence B, C, A n’est pas une
plus longue sous-séquence commune (PLSC) de X et Y, puisqu’elle est de longueur 3
et que la séquence B, C, B, A, qui est aussi commune à X et Y, est de longueur 4. La
séquence B, C, B, A est une PLSC de X et Y, de même que la séquence B, D, A, B,
puisqu’il n’existe pas de sous-séquence commune de longueur supérieure ou égale
à 5.
Dans le problème de la plus longue sous-séquence commune, on dispose au départ de deux séquences X = x1 , x2 , . . . , xm et Y = y1 , y2 , . . . , yn , et on souhaite
trouver une sous-séquence commune à X et Y de longueur maximale. Cette section
montrera que le problème de la PLSC peut être résolu efficacement grâce à la programmation dynamique.
a) Étape 1 : caractérisation d’une plus longue sous-séquence commune
La technique primaire de résolution du problème de la PLSC consiste à énumérer
toutes les sous-séquences de X et les tester pour voir si elles sont aussi des sousséquences de Y, en mémorisant au cours de la recherche la plus longue sous-séquence
15.4
Plus longue sous-séquence commune
343
trouvée. Chaque sous-séquence de X correspond à un sous-ensemble des indices
{1, 2, . . . , m} de X. Il existe 2m sous-séquences de X, de sorte que cette approche
demande un traitement à temps exponentiel, ce qui rend cette technique peu pratique
pour les longues séquences.
Or, le problème de la PLSC possède une propriété de sous-structure optimale,
comme le montre le théorème ci-après. Comme nous le verrons, les classes naturelles de sous-problèmes correspond à des paires de « préfixes » des deux séquences
d’entrée. Plus précisément, étant donnée une séquence X = x1 , x2 , . . . , xm , on définit
le ième préfixe de X, pour i = 0, 1, . . . , m, par Xi = x1 , x2 , . . . , xi . Par exemple, si
X = A, B, C, B, D, A, B, alors X4 = A, B, C, B et X0 représente la séquence vide.
Théorème 15.1 (Sous-structure optimale d’une PLSC) Soient deux séquences X = x1 ,
x2 , . . . , xm et Y = y1 , y2 , . . . , yn , et soit Z = z1 , z2 , . . . , zk une PLSC quelconque
de X et Y.
1) Si xm = yn , alors zk = xm = yn et Zk−1 est une PLSC de Xm−1 et Yn−1 .
2) Si xm fi yn , alors zk fi xm implique que Z est une PLSC de Xm−1 et Y.
3) Si xm fi yn , alors zk fi yn implique que Z est une PLSC de X et Yn−1 .
Démonstration : (1) Si zk fi xm , on peut concaténer xm = yn à Z pour obtenir une
c Dunod – La photocopie non autorisée est un délit
sous-séquence commune de X et Y de longueur k + 1, ce qui contredit l’hypothèse
selon laquelle Z est une plus longue sous-séquence commune de X et Y. On a donc
forcément zk = xm = yn . Or, le préfixe Zk−1 est une sous-séquence commune de longueur (k − 1) de Xm−1 et Yn−1 . On souhaite prouver que c’est une PLSC. Supposons,
en raisonnant par l’absurde, qu’il existe une sous-séquence commune W de Xm−1 et
Yn−1 de longueur plus grande que k−1. Alors, la concaténation de xm = yn à W produit
une sous-séquence commune à X et Y dont la longueur est plus grande que k, ce qui
aboutit à une contradiction.
(2) Si zk fi xm , alors Z est une sous-séquence commune de Xm−1 et Y. S’il existait une
sous-séquence commune W de Xm−1 et Y de taille supérieure à k, alors W serait aussi
une sous-séquence commune de Xm et Y, ce qui contredit l’hypothèse selon laquelle Z
est une PLSC de X et Y.
(3) La démonstration est la symétrique de (2).
❑
La caractérisation du théorème 15.1 montre qu’une PLSC de deux séquences contient
une PLSC de préfixes des deux séquences. Le problème de la PLSC possède donc une
propriété de sous-structure optimale. Une solution récursive possède également la
propriété de chevauchement des sous-problèmes, comme nous allons le voir bientôt.
b) Étape 2 : une solution récursive
Le théorème 15.1 implique que nous serons confrontés à un ou à deux sous-problèmes
pendant la recherche d’une PLSC de X = x1 , x2 , . . . , xm et Y = y1 , y2 , . . . , yn . Si
xm = yn , on doit trouver une PLSC de Xm−1 et Yn−1 . La concaténation de xm = yn à
cette PLSC engendre une PLSC de X et Y. Si xm fi yn , alors on doit alors résoudre
deux sous-problèmes : trouver une PLSC de Xm−1 et Y, et trouver une PLSC de
344
15 • Programmation dynamique
X et Yn−1 . La plus grande des deux PLSC, quelle qu’elle soit, est une PLSC de X
et Y. Comme ces cas épuisent toutes les possibilités, on sait que l’une des solutions
optimales des sous-problèmes doit servir dans une PLSC de X et Y.
On peut déjà apercevoir la propriété de chevauchement des sous-problèmes dans
le problème de la PLSC. Pour trouver une PLSC de X et Y, on peut avoir besoin de
trouver les PLSC de X et Yn−1 et de Xm−1 et Y. Mais chacun de ces sous-problèmes
contient le sous-sous-problème consistant à trouver la PLSC de Xm−1 et Yn−1 . Moult
autres sous-problèmes ont des sous-sous-problèmes en commun.
Comme pour le problème de la multiplication d’une suite de matrices, notre solution récursive du problème de la PLSC implique la création d’une récurrence pour
la valeur d’une solution optimale. Appelons c[i, j] la longueur d’une PLSC des séquences Xi et Yj . Si i = 0 ou j = 0, l’une des séquences a une longueur nulle, et donc
la PLSC est de longueur nulle. La sous-structure optimale du problème de la PLSC
débouche sur la formule récursive

si i = 0 ou j = 0 ,
 0
c[i
−
1,
j
−
1]
+
1
si
i, j > 0 et xi = yj ,
(15.14)
c[i, j] =

max(c[i, j − 1], c[i − 1, j]) si i, j > 0 et xi fi yj .
Observez que, dans cette formulation récursive, il y a une condition du problème
qui restreint la gamme des sous-problèmes à considérer. Quand xi = yj , on peut et
doit considérer le sous-problème consistant à trouver la PLSC de Xi−1 et Yj−1 . Autrement, on considère les deux sous-problèmes consistant à trouver la PLSC de Xi
et Yj−1 et celle de Xi−1 et Yj . Dans nos précédents algorithmes de programmation
dynamique (ordonnancement de chaîne de montage et multiplications de matrices),
aucun sous-problème n’était éliminé en raison de conditions inhérentes au problème.
Trouver la PLSC n’est pas le seul algorithme de programmation dynamique qui élimine des sous-problèmes en fonction de conditions posées dans le problème. Ainsi, le
problème de la distance d’édition (voir problème 15.3) possède cette caractéristique.
c) Étape 3 : calcul de la longueur d’une PLSC
En se rapportant à l’équation (15.14), on pourrait facilement écrire un algorithme récursif à temps exponentiel pour calculer la longueur d’une PLSC de deux séquences.
Cependant, comme il n’existe que Q(mn) sous-problèmes distincts, on peut faire appel à la programmation dynamique pour calculer les solutions de manière ascendante.
La procédure L ONGUEUR -PLSC prend deux séquences X = x1 , x2 , . . . , xm et
Y = y1 , y2 , . . . , yn en entrée. Elle stocke les valeurs c[i, j] dans un tableau c[0 . . m,
0 . . n] dont les éléments sont calculés dans l’ordre des lignes. (Autrement dit, la première ligne de c est remplie de la gauche vers la droite, puis la deuxième ligne, etc.)
Elle gère aussi un tableau b[1 . . m, 1 . . n] pour simplifier la construction d’une solution optimale. Intuitivement, b[i, j] pointe vers l’élément de tableau qui correspond à
la solution optimale du sous-problème choisie pendant le calcul de c[i, j]. La procédure retourne les tableaux b et c ; c[m, n] contient la longueur d’une PLSC de X et Y.
15.4
Plus longue sous-séquence commune
345
L ONGUEUR -PLSC(X, Y)
1 m ← longueur[X]
2 n ← longueur[Y]
3 pour i ← 1 à m
4
faire c[i, 0] ← 0
5 pour j ← 0 à n
6
faire c[0, j] ← 0
7 pour i ← 1 à m
8
faire pour j ← 1 à n
9
faire si xi = yj
10
alors c[i, j] ← c[i − 1, j − 1] + 1
11
b[i, j] ← « »
12
sinon si c[i − 1, j] c[i, j − 1]
13
alors c[i, j] ← c[i − 1, j]
14
b[i, j] ← « ↑ »
15
sinon c[i, j] ← c[i, j − 1]
16
b[i, j] ← « ← »
17 retourner c et b
La figure 15.6 montre les tableaux produits par L ONGUEUR -PLSC sur les séquences
X = A, B, C, B, D, A, B et Y = B, D, C, A, B, A. Le temps d’exécution de la procédure est O(mn), puisque chaque élément de tableau requiert un temps de calcul O(1).
j
c Dunod – La photocopie non autorisée est un délit
i
0
1
2
3
4
5
6
yj
B
D
C
A
B
A
0
xi
0
0
0
0
0
0
0
1
A
0
0
0
0
1
1
1
2
B
0
1
1
1
1
2
2
3
C
0
1
1
2
2
2
2
4
B
0
1
1
2
2
3
3
5
D
0
1
2
2
2
3
3
6
A
0
1
2
2
3
3
4
7
B
0
1
2
2
3
4
4
Figure 15.6 Les tableaux c et b calculés par L ONGUEUR -PLSC sur les séquences X = A, B, C , B,
D, A, B et Y = B, D, C , A, B, A. Le carré à l’intersection de la ligne i et de la colonne j contient la
valeur de c[i, j] et la flèche appropriée pour la valeur de b[i, j]. La valeur 4 de c[7, 6] (coin en bas
à droite dans le tableau) est la longueur d’une PLSC B, C , B, A de X et Y. Pour i, j > 0, l’élément
c[i, j] ne dépend que de l’égalité éventuelle entre xi et yj et des valeurs des éléments c[i − 1, j],
c[i, j − 1] et c[i − 1, j − 1], qui sont calculés avant c[i, j]. Pour raccorder les éléments d’une PLSC,
il suffit de suivre les flèches b[i, j] à partir du coin inférieur droit ; le chemin est indiqué en gris.
Chaque « » du chemin correspond à un élément (auréolé) pour lequel xi = yj est un membre
d’une PLSC.
346
15 • Programmation dynamique
d) Étape 4 : construction d’une PLSC
Le tableau b retourné par L ONGUEUR -PLSC peut servir à construire rapidement une
PLSC de X = x1 , x2 , . . . , xm et Y = y1 , y2 , . . . , yn . On commence tout simplement
en b[m, n] et on se déplace dans le tableau en suivant les flèches. Chaque fois que
nous rencontrons une « » dans l’élément b[i, j], on sait que xi = yj appartient à
la PLSC. Cette méthode permet de retrouver les éléments de la PLSC dans l’ordre
inverse. La procédure récursive suivante imprime une PLSC de X et Y dans le bon
ordre. L’appel initial est I MPRIMER -PLSC(b, X, longueur[X], longueur[Y]).
Pour le tableau b de la figure 15.6, cette procédure imprime « BCBA ». La procédure prend un temps O(m + n), puisqu’au moins l’un des deux indices i ou j est
décrémenté à chaque étape de la récursivité.
I MPRIMER -PLSC(b, X, i, j)
1 si i = 0 ou j = 0
2
alors retourner
3 si b[i, j] = « »
4
alors I MPRIMER -PLSC(b, X, i − 1, j − 1)
5
imprimer xi
6 sinon si b[i, j] = « ↑ »
7
alors I MPRIMER -PLSC(b, X, i − 1, j)
8 sinon I MPRIMER -PLSC(b, X, i, j − 1)
e) Amélioration du code
Une fois qu’un algorithme est développé, on s’aperçoit souvent qu’il est possible
d’améliorer son coût en temps ou en espace. Cela est particulièrement vrai des algorithmes qui appliquent directement les préceptes de la programmation dynamique.
Certaines modifications simplifient le code et améliorent les facteurs constants, mais
ne vont pas jusqu’à améliorer asymptotiquement l’efficacité. D’autres peuvent apporter des économies asymptotiques substantielles en temps et en espace.
Par exemple, on peut éliminer complètement le tableau b. Chaque élément c[i, j] ne
dépend que de trois autres éléments du tableau c : c[i−1, j−1], c[i−1, j], et c[i, j−1].
Etant donnée la valeur de c[i, j], on peut déterminer dans un temps O(1) laquelle
de ces trois valeurs a servi à calculer c[i, j], sans parcourir le tableau b. On peut
donc reconstruire une PLSC en temps O(m + n) en utilisant une procédure similaire à
I MPRIMER -PLSC. (L’exercice 15.4.2 vous demandera d’en donner le pseudo code.)
Bien que nous ayons ainsi économisé Q(mn) espace, l’espace auxiliaire requis pour
calculer une PLSC ne décroit pas asymptotiquement, puisque nous avons de toute
façon besoin d’un espace Q(mn) pour le tableau c.
Néanmoins, il est possible de réduire les besoins asymptotiques en espace de
L ONGUEUR -PLSC, puisque cette procédure ne fait appel qu’à deux lignes du tableau c à un instant donné : la ligne en cours de calcul, et la précédente. (En fait, on
15.5
Arbres binaires de recherche optimaux
347
peut n’utiliser qu’un peu plus de l’espace nécessaire pour une lignes de c pour calculer la longueur d’une PLSC. Voir exercice 15.4.4.) Cette optimisation n’est possible
que si l’on n’a besoin que de la longueur d’une PLSC ; si l’on doit reconstruire les
éléments d’une PLSC, le tableau réduit ne contient plus assez d’informations pour
retracer nos étapes en temps O(m + n).
Exercices
15.4.1 Déterminer une PLSC de 1, 0, 0, 1, 0, 1, 0, 1 et 0, 1, 0, 1, 1, 0, 1, 1, 0.
15.4.2 Montrer comment reconstruire une PLSC à partir du tableau c complet et des séquences initiales X = x1 , x2 , . . . , xm et Y = y1 , y2 , . . . , yn dans un temps O(m + n), sans
utiliser le tableau b.
15.4.3 Donner une version à recensement de L ONGUEUR -PLSC qui s’exécute en temps
O(mn).
15.4.4 Montrer comment calculer la longueur d’une PLSC en n’ayant recours qu’à 2· min(m, n)
éléments du tableau c, plus un espace supplémentaire O(1). Ensuite, montrer comment faire
la même chose en utilisant min(m, n) éléments plus un espace supplémentaire O(1).
15.4.5 Donner un algorithme à temps O(n2 ) pour trouver la plus longue sous-séquence monotone croissante d’une séquence de n nombres.
15.4.6 Donner un algorithme à temps O(n lg n) pour trouver la plus longue sous-séquence
monotone croissante d’une séquence de n nombres. (conseil : Remarquer que le dernier élément d’une sous-séquence idoine de longueur i est au moins aussi grand que le dernier élément d’une sous-séquence idoine de longueur i − 1. Mémoriser les sous-séquences idoines
en les reliant à l’intérieur de la séquence d’entrée.)
c Dunod – La photocopie non autorisée est un délit
15.5 ARBRES BINAIRES DE RECHERCHE OPTIMAUX
Supposez que l’on veuille écrire un programme qui traduise des textes de l’anglais
vers le français. Pour chaque occurrence de chaque mot anglais, il faut chercher
l’équivalent français. Une façon d’implémenter ces recherches consiste à créer un
arbre binaire de recherche contenant n mots anglais comme clés, les équivalents français étant les données satellite. Comme on explorera l’arbre pour chaque mot du texte,
on veut minimiser le temps total de consultation. On pourrait garantir un temps de
recherche O(lg n) par occurrence en employant un arbre rouge-noir, ou toute autre
espèce d’arbre binaire de recherche équilibré. Mais les mots ont des fréquences d’apparition différentes ; or, il peut advenir qu’un mot très courant tel que « the » soit loin
de la racine et qu’un mot rare comme « mycophagist » soit près de la racine. Une
telle disposition ralentit la traduction, vu que le nombre de nœuds visités lors de la
15 • Programmation dynamique
348
recherche d’une clé dans un arbre binaire vaut un de plus que la profondeur du nœud
contenant la clé. On veut que les mots qui reviennent souvent soient près de la racine.
(3) En outre, il peut exister des mots anglais pour lesquels il n’existe aucune traduction en français ; ces mots n’apparaissent donc pas du tout dans l’arbre. Comment
organiser un arbre binaire de recherche de façon à minimiser le nombre de nœuds
visités dans toutes les recherches, connaissant la fréquence d’apparition de chaque
mot ?
k2
k2
k1
d0
k4
d1
k1
k3
d2
k5
d3
d4
d0
k5
d1
d5
k4
k3
d2
(a)
d5
d4
d3
(b)
Figure 15.7 Deux arbres binaires de recherche pour un ensemble de n = 5 clés ayant les
probabilités suivantes :
i
pi
qi
0
0.05
1
0.15
0.10
2
0.10
0.05
3
0.05
0.05
4
0.10
0.05
5
0.20
0.10
(a) Un arbre binaire de recherche ayant un coût de recherche moyen de 2,80.
(b) Un arbre binaire de recherche ayant un coût de recherche moyen de 2,75. Cet arbre est
optimal.
Ce qu’il nous faut, c’est un arbre binaire de recherche optimal. Plus formellement, soit une séquence K = k1 , k2 , . . . , kn de n clés distinctes triées (de sorte que
k1 < k2 < · · · < kn ) ; on veut construire un arbre binaire de recherche optimal à partir de ces clés. Pour chaque clé ki , on a la probabilité pi qu’une recherche concerne ki .
Certaines recherches pouvant concerner des valeurs n’appartenant pas à K, on a donc
aussi n + 1 « clés factices » d0 , d1 , d2 , . . . , dn représentant des valeurs extérieures à K.
En particulier, d0 représente toutes les valeurs inférieures à k1 , dn représente toutes
les valeurs supérieures à kn et, pour i = 1, 2, . . . , n − 1, la clé factice di représente
toutes les valeurs comprises entre ki et ki+1 . Pour chaque clé factice di , on a une probabilité qi qu’une recherche concerne di . La figure 15.7 montre deux arbres binaires
de recherche pour un ensemble de n = 5 clés. Chaque clé ki est un nœud interne, et
chaque clé factice di est une feuille. Chaque recherche soit réussit (elle trouve une
(3) Si le texte traite des champignons comestibles, on pourrait vouloir que « mycophagist » soit près de la
racine.
15.5
Arbres binaires de recherche optimaux
349
certaine clé ki ), soit échoue (elle trouve une certaine clé factice di ), et donc on a
n
n
pi +
qi = 1 .
(15.15)
i=1
i=0
Comme on a des probabilités de recherche pour chaque clé et pour chaque clé factice, on peut déterminer le coût moyen d’une recherche dans un arbre binaire donné T.
Supposons que le coût réel d’une recherche soit le nombre de nœuds examinés, c’està-dire la profondeur du nœud trouvé par la recherche plus 1. Alors, le coût moyen
d’une recherche dans T est : E [coût de recherche dans T]
n
n
(profondeurT (ki ) + 1)·pi +
(profondeurT (di ) + 1)·qi
=
i=1
= 1+
i=0
n
i=1
profondeurT (ki )·pi +
n
profondeurT (di )·qi ,
(15.16)
i=0
c Dunod – La photocopie non autorisée est un délit
où profondeurT désigne la profondeur d’un nœud dans T. La dernière égalité découle
de l’équation (15.15). Sur la figure 15.7(a), on peut calculer le coût de recherche
moyen nœud par nœud :
nœud profondeur probabilité contribution
k1
1
0.15
0.30
0
0.10
0.10
k2
2
0.05
0.15
k3
1
0.10
0.20
k4
2
0.20
0.60
k5
2
0.05
0.15
d0
2
0.10
0.30
d1
3
0.05
0.20
d2
d3
3
0.05
0.20
3
0.05
0.20
d4
3
0.10
0.40
d5
Total
2.80
Pour un ensemble donné de probabilités, on veut construire un arbre binaire de
recherche qui minimise le coût de recherche moyen. À propos d’un tel arbre, on
parle d’arbre binaire de recherche optimal. La figure 15.7(b) montre un arbre binaire de recherche optimal pour les probabilités données dans la figure ; son coût
attendu est 2,75. Cet exemple montre qu’un arbre binaire de recherche optimal n’est
pas forcément un arbre dont la hauteur globale est minimale. L’exemple montre aussi
que l’on ne construit pas forcément un arbre binaire de recherche optimal en plaçant systématiquement à la racine la clé ayant la plus grande probabilité. Ici, c’est
la clé k5 qui a la probabilité de recherche maximale, et pourtant la racine de l’arbre
binaire de recherche optimal contient k2 . (Le coût moyen minimal d’un arbre binaire
de recherche ayant k5 comme racine est 2,85.)
350
15 • Programmation dynamique
Comme c’est le cas avec la multiplication de matrices en chaîne, la vérification
exhaustive de toutes les possibilités ne donne pas un algorithme efficace. On peut
étiqueter les nœuds d’un arbre binaire à n nœuds avec les clés k1 , k2 , . . . , kn pour
construire un arbre binaire de recherche, puis ajouter les clés factices comme feuilles.
Au problème 12.4, on a vu que le nombre d’arbres binaires à n nœuds est V(4n /n3/2 ) ;
il y aurait donc un nombre exponentiel d’arbres binaires à examiner si l’on adoptait
la méthode primitive. Vous ne serez pas étonné d’apprendre que nous allons résoudre
ce problème à l’aide de la programmation dynamique.
a) Étape 1 : structure d’un arbre binaire de recherche optimal
Pour caractériser la sous-structure optimale des arbres binaire de recherche optimaux, commençons par une observation sur les sous-arbres. Soit un sous-arbre d’un
arbre binaire de recherche. Il doit contenir des clés appartenant à une plage contiguë
ki , . . . , kj , pour un certain 1 i j n. En outre, un sous-arbre qui contient les clés
ki , . . . , kj doit avoir comme feuilles les clés factices di−1 , . . . , dj .
Nous pouvons maintenant énoncer la sous-structure optimale : si un arbre binaire
de recherche optimal T a un sous-arbre T contenant les clés ki , . . . , kj , alors ce sousarbre T est optimal pour le sous-problème ayant les clés ki , . . . , kj et les clés factices
di−1 , . . . , dj . L’argument couper-coller usuel s’applique : s’il y avait un sous-arbre T dont le coût moyen est inférieur à celui de T , alors on pourrait couper T dans T puis
coller T , ce qui donnerait un arbre binaire de recherche dont le coût est inférieur à
celui de T, ce qui contredirait l’optimalité de T.
Nous devons utiliser la sous-structure optimale pour montrer que l’on peut construire
une solution optimale du problème à partir de solutions optimales de sous-problèmes.
Soient les clés ki , . . . , kj ; l’une de ces clés, par exemple kr (i r j), est la racine
d’un sous-arbre optimal contenant ces clés. Le sous-arbre gauche de la racine kr
contiendra les clés ki , . . . , kr−1 (et les clés factices di−1 , . . . , dr−1 ) ; le sous-arbre
droite contiendra les clés kr+1 , . . . , kj (et les clés factices dr , . . . , dj ). Si l’on examine
toutes les candidats kr pour la place de racine (avec i r j) et si l’on détermine
tous les arbres binaire de recherche optimaux contenant ki , . . . , kr−1 et ceux contenant kr+1 , . . . , kj , alors on est certain de trouver un arbre binaire de recherche optimal.
Il y a un détail qui vaut la peine d’être noté, concernant les sous-arbres « vides ».
Supposez que, dans un sous-arbre de clés ki , . . . , kj , on sélectionne ki comme racine. De par le raisonnement précédent, le sous-arbre gauche de ki contient les clés
ki , . . . , ki−1 . Il est naturel d’interpréter cette séquence comme ne contenant aucune
clé. N’oubliez pas, toutefois, que les sous-arbres contiennent aussi des clés factices.
Nous adopterons la convention suivante : un sous-arbre contenant les clés ki , . . . , ki−1
n’a pas de clés véritables, mais il contient la clé factice di−1 . De manière symétrique, si l’on prend kj comme racine, alors le sous-arbre droite de kj contient les clés
kj+1 , . . . , kj ; ce sous-arbre ne contient pas de clés, mais contient la clé factice dj .
15.5
Arbres binaires de recherche optimaux
351
b) Étape 2 : une solution récursive
Nous voici parés pour définir récursivement la valeur d’une solution optimale. Définissons notre domaine de sous-problème : trouver un arbre binaire de recherche
optimal contenant les clés ki , . . . , kj , avec i 1, j n et j i − 1. (C’est quand
j = i − 1 qu’il n’y a pas de clés, mais juste la clé factice di−1 .) Soit e[i, j] le coût
moyen de recherche dans un arbre binaire de recherche optimal contenant les clés
ki , . . . , kj . Le but final est de calculer e[1, n].
Le cas facile se produit pour j = i − 1. On a alors uniquement la clé factice di−1 .
Le coût de recherche moyen est e[i, i − 1] = qi−1 .
Quand j i, on doit sélectionner une racine kr parmi ki , . . . , kj , puis faire un
arbre binaire de recherche optimal contenant les clés ki , . . . , kr−1 de son sous-arbre
gauche et faire un arbre binaire de recherche optimal contenant les clés kr+1 , . . . , kj
de son sous-arbre droite. Qu’advient-il du coût de recherche moyen d’un sous-arbre
quand il devient un sous-arbre d’un nœud ? La profondeur de chaque nœud du sousarbre augmente de 1. D’après l’équation (15.16), le coût de recherche moyen de ce
sous-arbre augmente de la somme de toutes les probabilités du sous-arbre. Pour un
sous-arbre de clés ki , . . . , kj , notons ainsi cette somme de probabilités
w(i, j) =
j
l=i
pl +
j
ql .
(15.17)
l=i−1
Donc, si kr est la racine d’un sous-arbre optimal contenant les clés ki , . . . , kj , on a
e[i, j] = pr + (e[i, r − 1] + w(i, r − 1)) + (e[r + 1, j] + w(r + 1, j)) .
En remarquant que
w(i, j) = w(i, r − 1) + pr + w(r + 1, j) ,
on réécrit e[i, j] ainsi
c Dunod – La photocopie non autorisée est un délit
e[i, j] = e[i, r − 1] + e[r + 1, j] + w(i, j) .
(15.18)
L’équation récursive (15.18) suppose que nous sachions quel est le nœud kr à
prendre comme racine. On choisit la racine qui donne le coût de recherche moyen
le plus faible ; d’où la formulation récursive finale :
e[i, j] =
si j = i − 1 ,
qi−1
min {e[i, r − 1] + e[r + 1, j] + w(i, j)} si i j .
(15.19)
i r j
Les valeurs e[i, j] donnent les coûts de recherche moyens dans des arbres binaires
de recherche optimaux. Pour nous aider à gérer la structure des arbres binaires de
recherche optimaux, définissons racine[i, j], pour 1 i j n, comme étant l’indice r pour lequel kr est la racine d’un arbre binaire de recherche optimal contenant
les clés ki , . . . , kj . Nous verrons comment calculer les valeurs de racine[i, j], mais
nous laisserons à l’exercice 15.5.1 le soin de construire l’arbre binaire de recherche
optimal à partir de ces valeurs.
15 • Programmation dynamique
352
c) Étape 3 : calcul du coût de recherche moyen dans un arbre binaire de
recherche optimal
À ce stade, vous avez peut-être noté certaines similitudes entre les caractérisations des
arbres binaires de recherche optimaux et des multiplications matricielles en chaîne.
Pour ces deux classes de problème, les sous-problèmes se composent de sousensembles d’indices contigus. Une implémentation récursive directe de l’équation
(15.19) serait aussi inefficace qu’un algorithme récursif direct de multiplications matricielles en chaîne. À la place, nous allons stocker les valeurs e[i, j] dans un tableau
e[1 . . n + 1, 0 . . n]. Le premier indice doit aller jusqu’à n + 1 et non n car, pour avoir
un sous-arbre ne contenant que la clé factice dn , on doit calculer et stocker e[n + 1, n].
Le second indice doit partir de 0 car, pour avoir un sous-arbre contenant uniquement
la clé factice d0 , on doit calculer et stocker e[1, 0]. On n’utilisera que les éléments
e[i, j] pour lesquels j i − 1. On emploiera aussi un tableau racine[i, j], pour mémoriser la racine du sous-arbre contenant les clés ki , . . . , kj . Ce tableau n’utilise que les
éléments pour lesquels 1 i j n.
On aura besoin d’un autre tableau, à des fins d’efficacité. Au lieu de calculer la
valeur de w(i, j) ex nihilo chaque fois que l’on calcule e[i, j] (ce qui prendrait Q(j − i)
additions), on stocke ces valeurs dans un tableau w[1 . . n + 1, 0 . . n]. Pour le cas de
base, on calcule w[i, i − 1] = qi−1 pour 1 i n. Pour j i, on calcule
w[i, j] = w[i, j − 1] + pj + qj .
(15.20)
Q(n2 )
valeurs de w[i, j] avec un temps Q(1) pour chaOn peut donc calculer les
cune. Le pseudo code qui suit prend en entrée les probabilités p1 , . . . , pn et q0 , . . . , qn
et la taille n, puis retourne les tableaux e et racine.
ABR-O PTIMAL(p, q, n)
1 pour i ← 1 à n + 1
2
faire e[i, i − 1] ← qi−1
3
w[i, i − 1] ← qi−1
4 pour l ← 1 à n
5
faire pour i ← 1 à n − l + 1
6
faire j ← i + l − 1
7
e[i, j] ← ∞
8
w[i, j] ← w[i, j − 1] + pj + qj
9
pour r ← i à j
10
faire t ← e[i, r − 1] + e[r + 1, j] + w[i, j]
11
si t < e[i, j]
12
alors e[i, j] ← t
13
racine[i, j] ← r
14 retourner e et racine
Compte tenu de la description précédente et de la similitude avec la procédure O RDRE C HAÎNE -M ATRICES de la section 15.2, vous devriez comprendre sans trop de problèmes
15.5
Arbres binaires de recherche optimaux
353
le fonctionnement de cette procédure. La boucle pour des lignes 1–3 initialise les
valeurs de e[i, i − 1] et w[i, i − 1]. La boucle pour des lignes 4–13 utilise ensuite les
récurrences (15.19) et (15.20) pour calculer e[i, j] et w[i, j] pour tout 1 i j n.
Dans la première itération, quand l = 1, la boucle calcule e[i, i] et w[i, i] pour
i = 1, 2, . . . , n. La deuxième itération, avec l = 2, calcule e[i, i + 1] et w[i, i + 1]
pour i = 1, 2, . . . , n − 1, etc. La boucle pour la plus interne, en lignes 9–13, essaie
chaque indice candidat r pour déterminer quelle est la clé kr à utiliser comme racine
d’un arbre binaire de recherche optimal contenant les clés ki , . . . , kj . Cette boucle
pour mémorise la valeur courante de l’indice r dans racine[i, j] chaque fois qu’elle
trouve une clé meilleure pour servir de racine.
La figure 15.8 montre les tables e[i, j], w[i, j] et racine[i, j] calculées par la procédure ABR-O PTIMAL pour la distribution de clés de la figure 15.7. Comme dans
l’exemple des produits de matrices en chaîne, on a fait tourner les tableaux pour
rendre horizontales les diagonales. ABR-O PTIMAL calcule les lignes du bas vers le
haut, puis de gauche à droite dans chaque ligne.
e
w
5
1
4 2.75
2
1.75 2.00
j
i
3
1.25
3
1.20 1.30
2
4
0.90
0.70
0.60
0.90
1
5
0.45 0.40 0.25 0.30 0.50
0
0.05
0.10
0.05
5
1
4 1.00
2
3 0.70 0.80
3
0.55 0.50 0.60
j
0.05
0.05
i
2
4
0.45
0.35
0.30
0.50
1
5
0 0.30 0.25 0.15 0.20 0.35
6
0.05 0.10 0.05 0.05 0.05 0.10
6
0.10
racine
5
j
2
3
2
2
1
1
c Dunod – La photocopie non autorisée est un délit
1
1
2
4
2
2
2
3
5
4
3
i
2
4
4
5
4
5
5
Figure 15.8 Tableaux e[i, j], w[i, j] et racine[i, j] calculés par ABR-O PTIMAL sur la distribution de
clés de la figure 15.7. Les tableaux sont inclinés de façon que les diagonales soient à l’horizontale.
ABR-O PTIMAL prend un temps Q(n3 ), tout comme O RDRE -C HAÎNE -M ATRICES.
Il est facile de voir que le temps d’exécution est O(n3 ), car les boucles pour sont imbriquées sur trois niveaux et chaque indice de boucle prend au plus n valeurs. Les indices de boucle de ABR-O PTIMAL n’ont pas exactement les mêmes limites que ceux
de O RDRE -C HAÎNE -M ATRICES, mais la différence est de 1 au plus dans toutes les directions. Donc, comme O RDRE -C HAÎNE -M ATRICES, la procédure ABR-O PTIMAL
prend un temps V(n3 ).
15 • Programmation dynamique
354
Exercices
15.5.1 Écrire le pseudo code de la procédure C ONSTRUIRE -ABR-O PTIMAL (racine) qui, à
partir de la table racine, affiche la structure d’un arbre binaire de recherche optimal. Pour
l’exemple de la figure 15.8, votre procédure devra afficher la structure
k2 est la racine
k1 est l’enfant gauche de k2
d0 est l’enfant gauche de k1
d1 est l’enfant droite de k1
k5 est l’enfant droite de k2
k4 est l’enfant gauche de k5
k3 est l’enfant gauche de k4
d2 est l’enfant gauche de k3
d3 est l’enfant droite de k3
d4 est l’enfant droite de k4
d5 est l’enfant droite de k5
correspondant à l’arbre binaire de recherche optimal montré sur la figure 15.7(b).
15.5.2 Déterminer le coût et la structure d’un arbre binaire de recherche optimal pour un
ensemble de n = 7 clés ayant les probabilités suivantes :
i
pi
qi
0
1
2
0.04 0.06
0.06 0.06 0.06
3
4
5
6
0.08 0.02 0.10 0.12
0.06 0.05 0.05 0.05
7
0.14
0.05
15.5.3 Supposez que, au lieu de gérer le tableau w[i, j], on calcule la valeur de w(i, j) directement à partir de l’équation (15.17) en ligne 8 de ABR-O PTIMAL puis que l’on utilise à la
ligne 10 cette valeur calculée. Comment cette modification affecterait-elle le temps d’exécution asymptotique de ABR-O PTIMAL ?
15.5.4 Knuth [184] a montré qu’il y a toujours des racines de sous-arbres optimaux telles
que racine[i, j − 1] racine[i, j] racine[i + 1, j] pour tout 1 i < j n. Utiliser ce fait
pour modifier ABR-O PTIMAL pour qu’elle tourne en temps Q(n2 ).
PROBLÈMES
15.1. Problème euclidien bitonique du voyageur de commerce
Le problème du voyageur de commerce euclidien consiste à déterminer la plus petite
tournée permettant de relier un ensemble donné de n points du plan. La figure 15.9(a)
montre la solution pour un problème à 7 points. Le problème général est NP-complet,
et il y a donc de fortes chances pour que sa solution demande un temps suprapolynomial (voir chapitre 34).
Problèmes
355
J. L. Bentley a suggéré de simplifier le problème en se restreignant aux tournées
bitoniques, autrement dit, celles qui partent du point le plus à gauche, continuent
strictement de gauche à droite vers le point le plus à droite, puis retournent vers
le point de départ en se déplaçant strictement de droite à gauche. La figure 15.9(b)
montre la plus courte tournée bitonique pour ces mêmes 7 points. Dans ce cas, on
peut trouver un algorithme à temps polynomial.
(a)
(b)
Figure 15.9 Sept points du plan, représentés sur une grille orthonormée. (a) La tournée la plus
courte, d’une longueur de 24, 88 . . . Cette tournée n’est pas bitonique. (b) La tournée bitonique
la plus courte pour le même ensemble de points. Sa longueur fait environ 25,58 . . .
c Dunod – La photocopie non autorisée est un délit
Décrire un algorithme à temps O(n2 ) pour déterminer une tournée bitonique optimale. Vous pouvez supposer que deux points n’ont jamais la même abscisse. (conseil :
Balayer le plan de gauche à droite, en mémorisant des possibilités optimales pour les
deux parties de la tournée.)
15.2. Impression équilibrée
On considère le problème d’une impression équilibrée d’un paragraphe sur une imprimante. Le texte d’entrée est une séquence de n mots de longueurs l1 , l2 , . . . , ln ,
mesurées en caractères. On souhaite imprimer ce paragraphe de façon équilibrée sur
un certain nombre de lignes qui contiennent un maximum de M caractères chacune.
Notre critère d’« équilibre » est le suivant. Si une ligne donnée contient les mots i
à j, où i j, et qu’on laisse exactement un espace entre deux mots, le nombre de
caractères d’espacement supplémentaires à la fin de la ligne est M − j + i − jk=i lk ,
qui doit être positif ou nul, pour que les mots tiennent sur la ligne. On souhaite minimiser la somme, sur toutes les lignes hormis la dernière, des cubes des nombres
de caractères d’espacement présents à la fin de chaque ligne. Donner un algorithme
de programmation dynamique permettant d’imprimer de manière équilibrée un paragraphe de n mots sur une imprimante. Analyser les besoins en temps et en espace de
votre algorithme.
15.3. Distance d’édition
Pour transformer une chaîne textuelle source x[1 . . m] en une chaîne cible y[1 . . n],
on peut effectuer diverses transformations. Notre but est le suivant : étant donnés x
15 • Programmation dynamique
356
et y, on veut produire une série de transformations qui changent x en y. On utilise un
tableau z (censé être suffisamment grand pour contenir tous les caractères nécessaires)
pour stocker les résultat intermédiaires. Initialement z est vide, et à la fin on doit avoir
z[j] = y[j] pour j = 1, 2, . . . , n. On gère les indices courants i pour x et j pour z ; les
opérations ont le droit de modifier z et ces indices. Initialement, i = j = 1. Comme on
est obligé d’examiner chaque caractère de x pendant la transformation, à la fin de la
séquence de transformations on doit avoir i = m + 1.
Il y a six transformations possibles :
Copier: un caractère de x vers z en faisant z[j] ← x[i], puis en incrémentant i et j.
Cette opération examine x[i].
Remplacer: un caractère de x par un autre caractère c en faisant z[j] ← c, puis en
incrémentant i et j. Cette opération examine x[i].
Supprimer: un caractère de x en incrémentant i mais en ne touchant pas à j. Cette
opération examine x[i].
Insérer: le caractère c dans z en faisant z[j] ← c, puis en incrémentant j sans toucher
à i. Cette opération n’examine aucun caractère de x.
Permuter: les deux caractères suivants, en les copiant de x vers z mais dans l’ordre
inverse ; pour ce faire, on fait z[j] ← x[i+1] et z[j+1] ← x[i] puis l’on fait i ← i+2
et j ← j + 2. Cette opération examine x[i] et x[i + 1].
Équeuter: le reste de x en faisant i ← m + 1. Cette opération examine tous les
caractères de x qui n’ont pas encore été examinés. Si l’on fait cette opération, ce
doit être la dernière de la série.
À titre d’exemple, une façon de transformer la chaîne source algorithm en la
chaîne cible altruistic est d’employer la séquence suivante d’opérations ; les
caractères soulignés représentent x[i] et z[j] après l’opération :
Opération
chaînes initiales
copier
copier
remplacer par t
supprimer
copier
insérer u
insérer i
insérer s
permuter
insérer c
équeuter
x
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
algorithm
z
a
al
alt
alt
altr
altru
altrui
altruis
altruisti
altruistic
altruistic
Notez que ce n’est pas la seule séquence de transformations qui change algorithm
en altruistic.
Problèmes
357
Chacune des opérations a un coût. Le coût d’une opération dépend de l’application
spécifique, mais on supposera ici que le coût de chaque opération est une constante
qui est connue. On supposera aussi que les coûts individuels des opérations copier
et remplacer sont inférieurs aux coûts combinés des opérations supprimer et insérer ;
sinon, on n’utiliserait pas les opérations copier et remplacer. Le coût d’une séquence
de transformations est la somme des coûts des opérations de la séquence. Pour la
séquence précédente, le coût de transformation de algorithm en altruistic
est
(3· coût(copier)) + coût(remplacer) + coût(supprimer) + (4· coût(insérer))
+ cot(permuter) + coût(équeuter) .
a. Étant données deux séquences x[1 . . m] et y[1 . . n] et un ensemble de coûts d’opération, la distance d’édition de x à y est le coût de la séquence d’opérations la plus
économique qui transforme x en y. Donner un algorithme de programmation dynamique qui détermine la distance d’édition de x[1 . . m] à y[1 . . n] et affiche une
séquence d’opérations optimale. Analyser les besoins en temps et en espace de cet
algorithme.
Le problème de la distance d’édition est une généralisation du problème de l’alignement de deux séquences ADN (voir, par exemple, Setubal et Meidanis [272, Section 3.2]). Il existe plusieurs méthodes pour mesurer la similitude de deux séquence
ADN en les alignant. L’une de ces méthodes d’alignement de deux séquences x et y
consiste à insérer des espaces à des emplacements arbitraires (extrémités comprises)
dans les deux séquences, de façon que les séquences résultantes x et y aient la même
longueur mais n’aient pas un espace au même emplacement (quel que soit j, on n’a
jamais un espace dans x [j] et y [j] à la fois.) Ensuite, on assigne une « pondération »
à chaque emplacement. La position j reçoit un poids de la façon suivante :
– +1 si x [j] = y [j] et aucun des deux n’est un espace,
c Dunod – La photocopie non autorisée est un délit
– −1 si x [j] fi y [j] et aucun des deux n’est un espace,
– −2 si l’un de x [j] ou y [j] est un espace.
Le poids de l’alignement est la somme des poids des emplacements. Ainsi, étant
données les séquences x = GATCGGCAT et y = CAATGTGAATC, un alignement
possible est
G ATCG GCAT
CAAT GTGAATC
-*++*+*+-++*
Un + sous un emplacement indique une pondération de +1, un - indique un poids
de −1 et un * indique un poids de −2 ; cet alignement a donc un poids totale de
6·1 − 2·1 − 4·2 = −4.
b. Expliquer comment transformer le problème de l’alignement optimal en un problème de distance d’édition utilisant un sous-ensemble des transformations copier,
remplacer, supprimer, insérer, permuter et équeuter.
358
15 • Programmation dynamique
15.4. Planification d’un raout d’entreprise
Le professeur Knock est consultant pour le compte du PDG d’une entreprise qui
veut organiser un raout d’entreprise. L’entreprise a une structure hiérarchisée arborescente, dont le PDG est la racine. Le service du personnel a affecté à chaque employé
une note de convivialité, qui est un nombre réel. Pour que le raout soit une vraie fête
pour tout le monde, le PDG ne veut pas inviter en même temps un employé et son
supérieur direct.
Le professeur Knock se voit remettre l’arborescence qui décrit la structure de l’entreprise à l’aide de la représentation enfant-gauche, frère-droite vue à la section 10.4.
Chaque nœud de l’arborescence contient, outre les pointeurs, le nom d’un employé
et sa note de convivialité. Décrire un algorithme pour établir une liste d’invités qui
maximise le total des notes de convivialité des invités. Analyser le temps d’exécution
de cet algorithme.
15.5. Algorithme de Viterbi
On peut se servir de la programmation dynamique sur un graphe orienté G = (S, A)
pour la reconnaissance vocale. Chaque arc (u, v) ∈ A est étiqueté par un phonème
s(u, v) tiré d’un ensemble fini S de phonèmes. Le graphe ainsi étiqueté modélise une
personne parlant un langage restreint. Chaque chemin du graphe partant d’un sommet
distingué v0 ∈ S correspond à une séquence possible de phonèmes produite par le
modèle. L’étiquette d’un chemin orienté est définie comme étant la concaténation
des étiquettes des arcs du chemin.
a. Décrire un algorithme efficace qui, étant donnés un graphe G à arcs étiquetés et
sommet distingué v0 et une séquence s = s1 , s2 , . . . , sk de caractères de S,
retourne un chemin de G qui commence à v0 et a l’étiquette s, si un tel chemin
existe. Sinon, l’algorithme retournera CHEMIN - INEXISTANT. Analyser le temps
d’exécution de votre algorithme. (Conseil : Les concepts abordés au chapitre 22
pourront vous être utiles.)
Supposons maintenant qu’on attribue aussi à chaque arc (u, v) ∈ A une probabilité
positive ou nulle p(u, v), représentant les chances de passer par l’arc (u, v) à partir du
sommet u et donc de produire le son correspondant. La somme des probabilités des
arcs partant d’un sommet quelconque est égale à 1. La probabilité d’un chemin est
définie comme étant le produit des probabilités de ses arcs. On peut voir la probabilité
d’un chemin commençant en v0 comme étant la probabilité pour qu’une « marche
aléatoire » commençant en v0 suive le chemin en question, le choix de l’arc à prendre
à partir d’un sommet u se faisant selon les probabilités des arcs disponibles depuis u.
b. Étendre la réponse de la question (a) de façon que le chemin retourné soit un
chemin le plus probable partant de v0 et ayant l’étiquette s. Analyser le temps
d’exécution de votre algorithme.
Notes
359
15.6. Déplacement sur un damier
Supposez que vous ayez un damier n × n et un jeton. Vous devez déplacer le jeton depuis le bord inférieur du damier vers le bord supérieur, en respectant la règle suivante.
À chaque étape, vous pouvez placer le jeton sur l’un des trois carrés suivants :
1) le carré qui est juste au dessus,
2) le carré qui est situé une position plus haut et une position plus à gauche (à condition que le jeton ne soit pas déjà dans la colonne la plus à gauche),
3) le carré qui est situé une position plus haut et une position plus à droite (à condition
que le jeton ne soit pas déjà dans la colonne la plus à droite).
Chaque fois que vous passez du carré x au carré y, vous recevez p(x, y) euros. On
vous donne p(x, y) pour toutes les paires (x, y) correspondant à un déplacement licite
de x à y. p(x, y) n’est pas forcément positif.
Donner un algorithme pour déterminer l’ensemble des déplacements qui feront
passer le jeton du bord inférieur au bord supérieur, tout en vous faisant empocher
le maximum d’euros. Votre algorithme peut partir de n’importe quel carré du bord
inférieur et arriver sur n’importe quel carré du bord supérieur pour maximiser le
montant collecté au cours du trajet. Quel est le temps d’exécution ?
15.7. Ordonnancement à profit maximal
Vous avez une machine et n tâches a1 , a2 , . . . , an à traiter sur cette machine. Chaque
tâche aj a une durée d’exécution tj , un profit pj et une date d’échéance dj . La machine
ne peut traiter qu’une seule tâche à la fois, et la tâche aj doit s’exécuter sans interruption pendant tj unités de temps consécutives. Si la tâche aj est terminée avant la
limite dj , vous recevez un gain pj ; sinon, vous ne recevez rien. Donner un algorithme
pour déterminer l’ordonnancement permettant d’obtenir le profit maximal, en supposant que tous les temps de traitement soient des entiers compris entre 1 et n. Quel est
le temps d’exécution ?
c Dunod – La photocopie non autorisée est un délit
NOTES
R. Bellman commença l’étude systématique de la programmation dynamique en 1955. Le
mot « programmation », dans ce contexte comme dans celui de la programmation linéaire,
fait référence à l’utilisation d’un méthode de résolution tabulaire. Bien que des techniques
d’optimisation incorporant des éléments de programmation dynamique fussent connues depuis longtemps, Bellman les justifia par une solide base mathématique. [34].
Hu et Shing [159, 160] donnent un algorithme à temps O(n lg n) pour le problème de la
multiplication d’une suite de matrices.
L’algorithme à temps O(mn) donné pour le problème de la plus longue sous-séquence
commune semble être un algorithme utilisé depuis longtemps. Knuth [63] posa la question
de savoir si des algorithmes sous-quadratiques existaient pour le problème de la PLSC. Masek
360
15 • Programmation dynamique
et Paterson [212] répondirent à cette question par l’affirmative, en donnant un algorithme qui
s’exécute en temps O(mn/ lg n), où n m et où les séquences sont tirées d’un ensemble
de taille bornée. Pour le cas particulier où aucun élément n’apparaît plus d’une fois dans
la séquence d’entrée, Szymanski [288] montre que le problème peut être résolu en temps
O((n + m) lg(n + m)). Beaucoup de ces résultats s’étendent au problème du calcul de distances
d’édition de chaînes. (problème 15.3).
Un premier article sur les encodages binaires de longueur variable, dû à Gilbert et Moore
[114], expliquait comment construire des arbres binaires de recherche optimaux pour le cas
où toutes les probabilités pi sont 0 ; cet article contenait un algorithme à temps O(n3 ). Aho,
Hopcroft et Ullman [5] présentent l’algorithme de la section 15.5. L’exercice 15.5.4 est dû à
Knuth [184]. Hu et Tucker [161] ont conçu un algorithme pour le cas où toutes les probabilités
pi sont 0, qui utilise O(n2 ) temps et O(n) espace ; par la suite, Knuth [185] a réduit le temps à
O(n lg n).
Chapitre 16
Algorithmes gloutons
c Dunod – La photocopie non autorisée est un délit
Les algorithmes pour problèmes d’optimisation exécutent en général une série
d’étapes, chaque étape proposant un ensemble de choix. Pour de nombreux problèmes d’optimisation, la programmation dynamique est une approche bien trop
lourde pour déterminer les meilleurs choix ; d’autres algorithmes, plus simples et
plus efficaces, peuvent faire l’affaire. Un algorithme glouton fait toujours le choix
qui lui semble le meilleur sur le moment. Autrement dit, il fait un choix localement
optimal dans l’espoir que ce choix mènera à une solution globalement optimale.
Ce chapitre étudie les problèmes d’optimisation qui peuvent se résoudre par des
algorithmes gloutons. Avant de lire ce chapitre, mieux vaut avoir lu le chapitre 15
consacré à la programmation dynamique, et notamment la section 15.3.
Les algorithmes gloutons n’aboutissent pas toujours à des solutions optimales,
mais ils y arrivent dans de nombreux cas. Nous commencerons par examiner à la
section 16.1 un problème simple mais non trivial, à savoir le problème du choix d’activités, pour lequel un algorithme glouton calcule une solution efficacement. Nous
arriverons à l’algorithme glouton en commençant par étudier une solution basée sur
la programmation dynamique, puis en montrant que l’on peut toujours faire des choix
gloutons pour arriver à une solution optimale. La section 16.2 passera en revue les
éléments fondamentaux de l’approche gloutonne et donnera une méthode plus directe, pour prouver la validité des algorithmes gloutons, que le processus de la section 16.1 basé sur la programmation dynamique. La section 16.3 présentera une application importante des techniques gloutonnes : la conception de codes (de Huffman)
de compression de données. Dans la section 16.4, nous nous étudierons des structures
combinatoires appelées « matroïdes » pour lesquelles un algorithme glouton produit
362
16 • Algorithmes gloutons
toujours une solution optimale. Enfin, la section 16.5 illustrera l’utilité des matroïdes
sur le problème de l’ordonnancement temporel de tâches, avec dates d’échéance et
pénalités.
La méthode gloutonne est très puissante et fonctionne bien pour toutes sortes
de problèmes. Des chapitres ultérieurs présenteront de nombreux algorithmes qui
peuvent être vus comme des applications de la méthode gloutonne, notamment les
algorithmes d’arbre couvrant minimal (chapitre 23), l’algorithme de Dijkstra qui calcule des plus courts chemins à origine unique (chapitre 24) et l’heuristique gloutonne
de Chvátal pour le recouvrement d’ensemble (chapitre 35). Les algorithmes d’arbre
couvrant minimal sont un exemple classique de la méthode gloutonne. Bien que ce
chapitre et le chapitre 23 puissent être lus indépendamment, il peut être utile de les
lire ensemble.
16.1 UN PROBLÈME DE CHOIX D’ACTIVITÉS
Notre premier exemple concernera le problème de l’ordonnancement de plusieurs
activités qui rivalisent pour l’utilisation exclusive d’une ressource commune, l’objectif étant de sélectionner un ensemble de taille maximale d’activités mutuellement
compatibles. Supposez que l’on ait un ensemble S = {a1 , a2 , . . . , an } de n activités
proposées qui veulent utiliser une ressource, par exemple une salle de conférences qui
ne peut servir qu’à une seule activité à la fois. Chaque activité ai a une heure de début
si et une heure de fin fi , avec 0 si < fi < ∞. Si elle est sélectionnée, l’activité ai a
lieu pendant l’intervalle temporel semi-ouvert [si , fi ). Les activités ai et aj sont compatibles si les intervalles [si , fi ) et [sj , fj ) ne se chevauchent pas (c’est-à-dire que ai et
aj sont compatibles si si fj ou sj fi ). Le problème du choix d’activités consiste à
sélectionner un sous-ensemble, de taille maximale, d’activités mutuellement compatibles. Par exemple, considérons l’ensemble suivant S d’activités, que nous avons trié
par ordre croissant d’heure de fin :
i 1 2 3 4 5 6 7 8 9 10 11
si 1 3 0 5 3 5 6 8 8 2 12
fi 4 5 6 7 8 9 10 11 12 13 14
(Nous verrons bientôt l’intérêt de traiter les activités dans cet ordre.) Pour cet
exemple, le sous-ensemble {a3 , a9 , a11 } se compose d’activités mutuellement compatibles. Ce n’est pas un sous-ensemble maximum, cependant, car le sous-ensemble
{a1 , a4 , a8 , a11 } est plus grand. En fait, {a1 , a4 , a8 , a11 } est un sous-ensemble maximum d’activités mutuellement compatibles ; un autre sous-ensemble maximum est
{a2 , a4 , a9 , a11 }.
Nous résoudrons ce problème en plusieurs étapes. Nous commencerons par formuler une solution basée sur la programmation dynamique, dans laquelle on combine
les solutions optimales de deux sous-problèmes pour former une solution optimale du
problème initial. On envisagera plusieurs choix quand on déterminera quels sont les
16.1
Un problème de choix d’activités
363
sous-problèmes à utiliser dans une solution optimale. On observera alors qu’il suffit
de considérer un seul choix, à savoir le choix glouton, et que, quand on opte pour
le choix glouton, on a la certitude que l’un des sous-problèmes sera vide et qu’il ne
restera donc qu’un seul sous-problème. En partant de ces observations, nous développerons un algorithme glouton récursif pour résoudre le problème de l’ordonnancement d’activités. Nous terminerons le processus de développement d’une solution
gloutonne en convertissant l’algorithme récursif en algorithme itératif. Bien que les
étapes par lesquelles nous passerons dans cette section soient plus complexes que
ce qui se fait généralement pour le développement d’un algorithme glouton, elles
illustrent bien les relations qui existent entre algorithmes gloutons et programmation
dynamique.
a) La sous-structure optimale du problème du choix d’activités
Comme précédemment mentionné, nous commencerons par développer une solution basée sur la programmation dynamique pour le problème du choix d’activités.
Comme dans le chapitre 15, notre première étape sera de trouver la sous-structure
optimale puis de nous en servir pour construire une solution optimale du problème à
partir des solutions optimales de sous-problèmes.
Nous avons vu au chapitre 15 que nous avons besoin de définir un espace approprié
de sous-problèmes. Commençons par définir les ensembles
Sij = {ak ∈ S : fi sk < fk sj } ,
c Dunod – La photocopie non autorisée est un délit
tels que Sij soit le sous-ensemble d’activités de S qui peuvent démarrer après que l’activité ai s’est terminée et finir avant que l’activité aj démarre. En fait, Sij se compose
de toutes les activités qui sont compatibles avec ai et aj , et qui sont aussi compatibles
avec toutes les activités qui ne finissent pas plus tard que ai et avec toutes les activités qui ne commencent pas plus tôt que aj . Pour représenter l’ensemble du problème,
nous ajouterons des activités fictives a0 et an+1 et adopterons les conventions selon
lesquelles f0 = 0 et sn+1 = ∞. Alors, S = S0,n+1 et les intervalles de i et j sont donnés
par 0 i, j n + 1.
Nous pouvons restreindre encore les intervalles de i et j, de la manière suivante.
Supposons que les activités soient triées par ordre monotone croissant d’heure de fin :
f0 f1 f2 · · · fn < fn+1 .
(16.1)
Nous affirmons que Sij = ∅ chaque fois que i j. Pourquoi ? Supposons qu’il
existe une activité ak ∈ Sij pour un certain couple i j, de sorte que ai suit aj dans
notre ordre. On aurait alors fi sk < fk sj < fj . Donc, on aurait fi < fj , ce qui
contredit notre hypothèse que ai suit aj dans notre ordre. On en conclut que, si l’on
a trié les activités par ordre monotone croissant d’heure de fin, notre espace de sousproblèmes consiste à sélectionner un sous-ensemble, de taille maximale, d’activités
mutuellement compatibles prises dans Sij , pour 0 i < j n + 1, sachant que tous
les autres Sij sont vides.
16 • Algorithmes gloutons
364
Pour voir la sous-structure du problème de choix d’activités, considérons un certain sous-problème non vide Sij , (1) et supposons qu’une solution à Sij contienne une
certaine activité ak , de sorte que fi sk < fk sj . Utiliser l’activité ak génère deux
sous-problèmes, Sik (activités qui démarrent après que ai a fini et qui finissent avant
que ak démarre) et Skj (activités qui démarrent après que ak a fini et qui finissent
avant que aj démarre), dont chacun se compose d’un sous-ensemble des activités
de Sij . Notre solution à Sij est l’union des solutions à Sik et des solutions à Skj , plus
l’activité ak . Donc, le nombre d’activités de notre solution à Sij est égale à la taille de
notre solution à Sik , plus la taille de notre solution à Skj , plus un (pour ak ).
La sous-structure optimale de ce problème est la suivante. Supposons maintenant
qu’une solution optimale Aij de Sij contienne l’activité ak . Alors, les solutions Aik
de Sik et Akj de Skj utilisées dans cette solution optimale de Sij doivent être, elles
aussi, optimales. On peut appliquer le raisonnement « couper-coller » habituel. Si l’on
avait une solution Aik de Sik qui contienne plus d’activités que Aik , on couperait Aik
de Aij et on le collerait dans Aik , ce qui produirait une autre solution de Sij contenant
plus d’activités que Aij . Comme on a supposé que Aij est une solution optimale, on
aboutit à une contradiction. De même, si l’on avait une solution Akj de Skj ayant plus
d’activités que Akj , on pourrait remplacer Akj par Akj pour produire une solution de Sij
ayant plus d’activités que Aij .
Nous allons maintenant utiliser notre sous-structure optimale pour montrer que
nous pouvons construire une solution optimale du problème à partir de solutions optimales de sous-problèmes. Nous avons vu que toute solution d’un sous-problème
non vide Sij contient une certaine activité ak , et que toute solution optimale contient
en elle des solutions optimales des instances de sous-problème Sik et Skj . Donc, on
peut construire un sous-ensemble de taille maximale d’activités mutuellement compatibles de Sij en divisant le problème en deux sous-problèmes (trouver des sousensembles, de taille maximale, d’activités mutuellement compatibles de Sik et Skj ),
en trouvant des sous-ensembles de taille maximale Aik et Akj d’activités mutuellement compatibles pour ces sous-problèmes, puis en formant notre sous-ensemble de
taille maximale Aij d’activités mutuellement compatibles de la façon suivante
Aij = Aik ∪ {ak } ∪ Akj .
(16.2)
Une solution optimale pour le problème tout entier est une solution de S0,n+1 .
b) Une solution récursive
La deuxième phase de la création d’une solution basée sur la programmation dynamique consiste à définir récursivement la valeur d’une solution optimale. Pour le
problème du choix d’activités, soit c[i, j] le nombre d’activités d’un sous-ensemble de
(1) Nous parlerons parfois, à propos des ensembles Sij , de sous-problèmes au lieu d’ensembles d’activités. Le
contexte indiquera toujours si l’on se réfère à Sij en tant qu’ensemble d’activités, ou en tant que sous-problème
dont l’entrée est cet ensemble.
16.1
Un problème de choix d’activités
365
taille maximale d’activités mutuellement compatibles de Sij . On a c[i, j] = 0 chaque
fois que Sij = ∅ ; en particulier, c[i, j] = 0 pour i j.
Considérons maintenant un sous-ensemble non vide Sij . Nous avons vu que, si
ak appartient à un sous-ensemble de taille maximale d’activités mutuellement compatibles de Sij , on utilise aussi des sous-ensembles de taille maximale d’activités
mutuellement compatibles pour les sous-problèmes Sik et Skj . En utilisant l’équation (16.2), on obtient la récurrence
c[i, j] = c[i, k] + c[k, j] + 1 .
Cette équation récursive suppose que l’on connaisse la valeur de k, que nous ne
connaissons pas. Il y a j − i − 1 valeurs possibles pour k, à savoir k = i + 1, . . . , j − 1.
Comme le sous-ensemble de taille maximale de Sij doit utiliser l’une de ces valeurs
pour k, on les teste toutes afin de trouver la meilleure. Notre définition entièrement
récursive de c[i, j] devient donc
0
c[i, j] =
max
i<k<j et ak ∈Sij
si Sij = ∅ ,
{c[i, k] + c[k, j] + 1} si Sij fi ∅ .
(16.3)
c) Conversion d’une solution de type programmation dynamique en une
solution gloutonne
À ce stade, il serait on ne peut plus facile d’écrire un algorithme de programmation dynamique, tabulaire et ascendant (bottom-up), basé sur la récurrence (16.3). En
fait, ce sera justement l’objet de l’exercice 16.11. Il y a, toutefois, deux observations
majeures qui vont nous permettre de simplifier notre solution.
Théorème 16.1 Soit un sous-problème non vide Sij et soit am l’activité de Sij ayant
l’heure de fin la plus précoce :
fm = min {fk : ak ∈ Sij } .
c Dunod – La photocopie non autorisée est un délit
Alors
1) L’activité am est utilisée dans un certain sous-ensemble de taille maximale d’activités mutuellement compatibles de Sij .
2) Le sous-problème Sim est vide, de sorte que choisir am fait du sous-problème Smj
le seul sous-problème susceptible d’être non vide.
Démonstration : Nous allons commencer par démontrer la seconde partie, vu
qu’elle est un peu plus simple. Supposons que Sim soit non vide, auquel cas il existe
une activité ak telle que fi sk < fk sm < fm . Alors, ak est aussi dans Sij et elle
a une heure de fin antérieure à celle de am , ce qui contredit notre choix de am . On en
conclut que Sim est vide.
Pour prouver la première partie, supposons que Aij soit un sous-ensemble de taille
maximale d’activités mutuellement compatibles de Sij , et rangeons les activités de Aij
par ordre monotone croissant d’heure de fin. Soit ak la première activité de Aij .
366
16 • Algorithmes gloutons
Si ak = am , on a fini, car on a montré que am est utilisée dans un sous-ensemble de
taille maximale d’activités mutuellement compatibles de Sij . Si ak fi am , construisons
le sous-ensemble Aij = Aij − {ak } ∪ {am }. Les activités de Aij sont disjointes, vu
que les activités de Aij le sont, ak est la première activité de Aij à finir et fm fk .
En remarquant que Aij a le même nombre d’activités que Aij , on voit que Aij est un
sous-ensemble de taille maximale d’activités mutuellement compatibles de Sij qui
contient am .
❑
Pourquoi le théorème 16.1 est-il si précieux ? Rappelez-vous (voir section 15.3)
que la sous-structure optimale dépend du nombre de sous-problèmes qui sont utilisés dans une solution optimale du problème original, ainsi que du nombre de choix
que nous avons pour déterminer quels sont les sous-problèmes à utiliser. Dans notre
solution de type programmation dynamique, il y a deux sous-problèmes qui sont
utilisés dans une solution optimale et il y a j − i − 1 choix quand on résout le sousproblème Sij . Le théorème 16.1 réduit sensiblement ces deux quantités : il ne reste
plus qu’un sous-problème dans une solution optimale (l’autre étant forcément vide)
et, quand on résout le sous-problème Sij , on n’a plus qu’un seul choix à considérer,
à savoir celui qui a l’heure de fin la plus précoce dans Sij . Fort heureusement, il est
facile de déterminer de quelle activité il s’agit.
Outre la réduction du nombre de sous-problèmes et du nombre de choix, le théorème 16.1 procure un autre avantage : on peut résoudre chaque sous-problème de
manière descendante (top-down), et non de manière ascendante comme c’est généralement le cas en programmation dynamique. Pour résoudre le sous-problème Sij ,
on choisit l’activité am de Sij qui a l’heure de fin la plus précoce et l’on ajoute à
cette solution l’ensemble des activités utilisées dans une solution optimale du sousproblème Smj . Comme on sait que, ayant choisi am , on va certainement utiliser une
solution de Smj dans notre solution optimale de Sij , on n’a pas besoin de résoudre
Smj avant de résoudre Sij . Pour résoudre Sij , on peut d’abord choisir am comme étant
l’activité de Sij qui a l’heure de fin la plus précoce et ensuite résoudre Smj .
Notez aussi qu’il y a une loi générale sous-jacente aux sous-problèmes que nous
résolvons. Notre problème originel est S = S0,n+1 . Supposons que nous choisissions
am1 comme étant l’activité de S0,n+1 qui a l’heure de fin la plus précoce. (Comme nous
avons trié les activités par ordre monotone croissant d’heure de fin et que f0 = 0, on a
forcément m1 = 1.) Notre sous-problème suivant est Sm1 ,n+1 . Supposons maintenant
que nous choisissions am2 comme étant l’activité de Sm1 ,n+1 qui a l’heure de fin la
plus précoce. (On n’a pas obligatoirement m2 = 2.) Notre sous-problème suivant est
Sm2 ,n+1 . En continuant ainsi, on voit que chaque sous-problème est de la forme Smi ,n+1
où mi désigne un certain numéro d’activité. Autrement dit, chaque sous-problème se
compose des dernières activités à finir et le nombre de ces activités varie d’un sousproblème à l’autre.
Il y a aussi une loi générale sous-jacente aux activités que nous choisissons.
Comme nous choisissons systématiquement l’activité qui a l’heure de fin la plus
précoce dans Smi ,n+1 , les heures de fin des activités choisies, tous sous-problèmes
16.1
Un problème de choix d’activités
367
confondus, croissent strictement avec le temps. En outre, on peut traiter chaque
activité une seule fois, dans l’ordre monotone croissant des heures de fin.
L’activité am que nous choisissons quand nous résolvons un sous-problème est
toujours celle qui a l’heure de fin la plus précoce susceptible d’être planifiée en toute
légalité. L’activité sélectionnée est donc un choix « glouton », au sens où intuitivement, elle laisse le maximum de possibilités pour les activités restant à planifier.
Autrement dit, le choix glouton est celui qui maximise la quantité restante de temps
non planifié.
d) Un algorithme glouton récursif
c Dunod – La photocopie non autorisée est un délit
Maintenant que nous avons vu comment raffiner notre solution basée sur la programmation dynamique et comment la traiter comme une méthode descendante (topdown), nous sommes parés pour voir un algorithme descendant qui fonctionne de
manière 100 % gloutonne. Nous donnerons une solution récursive directe, sous la
forme de la procédure C HOIX -D’ACTIVITÉS -R ÉCURSIF. Elle prend en entrée les
heures de début et de fin des activités, représentées par les tableaux s et f , ainsi
que les indices i et n définissant le sous-problème Si,n+1 qu’elle doit résoudre. (Le
paramètre n indexe la dernière activité réelle an du sous-problème, et non l’activité fictive a( n + 1) qui appartient elle aussi au sous-problème.) Elle retourne un
ensemble, de taille maximale, d’activités mutuellement compatibles de Si,n+1 . On
suppose que les n activités données en entrée sont triées par ordre monotone croissant d’heures de fin, selon l’équation (16.1). Si tel n’est pas le cas, on peut les
trier en temps O(n lg n) en rompant les égalités arbitrairement. L’appel initial est
C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , 0, n).
C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , i, n)
1 m←i+1
trouver première activité de Si,n+1 .
2 tant que m n et sm < fi
3
faire m ← m + 1
4 si m n
5
alors retourner {am } ∪ C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , m, n)
6
sinon retourner ∅
La figure 16.1 montre le fonctionnement de l’algorithme. Dans un appel récursif
donné C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , i, n), la boucle tant que des lignes 2–3
recherche la première activité de Si,n+1 . La boucle examine ai+1 , ai+2 , . . . , an , jusqu’à
ce qu’elle trouve la première activité am qui est compatible avec ai ; une telle activité
a sm fi . Si la boucle se termine parce qu’elle trouve une telle activité, la procédure
retourne en ligne 5 l’union de {am } et du sous-ensemble de taille maximale de Sm,n+1
retourné par l’appel récursif C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , m, n). La boucle
peut aussi se terminer parce que m n, auquel cas on a examiné toutes les activités
sans en trouver une qui soit compatible avec ai . Dans ce cas, Si,n+1 = ∅, et donc la
procédure retourne ∅ en ligne 6.
16 • Algorithmes gloutons
368
k
sk
fk
0
–
0
1
1
4
2
3
5
3
0
6
4
5
7
a0
a1
a0
CHOIX-D'ACTIVITÉS-RÉCURSIF (s, f, 0, 12)
m=1
a2
CHOIX-D'ACTIVITÉS-RÉCURSIF (s, f, 1, 12)
a1
a3
a1
a4
a1
m=4
CHOIX-D'ACTIVITÉS-RÉCURSIF (s, f, 4, 12)
5
3
8
6
5
9
7
6
10
8
8
11
9
8
12
10
2
13
11
12
14
12
∞
–
a5
a1
a4
a1
a4
a1
a4
a1
a4
a6
a7
a8
m=8
a9
CHOIX-D'ACTIVITÉS-RÉCURSIF (s, f, 8, 12)
a1
a4
a8
a10
a1
a4
a8
a1
a4
a8
m = 11
a8
a11
a11
CHOIX-D'ACTIVITÉS-RÉCURSIF (s, f, 11, 12)
a1
a4
temps
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Figure 16.1 Fonctionnement de C HOIX -D’ACTIVITÉS -R ÉCURSIF sur les 11 activités précédemment données. Les activités traitées dans chaque appel récursif apparaissent entre
des traits horizontaux. L’activité fictive a0 finit à l’heure 0 et, dans l’appel initial
C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , 0, 11), l’activité a1 est sélectionnée. Dans chaque appel récursif, les activités qui ont été déjà sélectionnées sont estompées, et l’activité en blanc est celle qui
est en cours de traitement. Si l’heure de début d’une activité a lieu avant l’heure de fin de l’activité
ajoutée en dernier (la flèche entre elles pointe vers la gauche), elle est refusée. Sinon (la flèche
pointe directement vers le haut ou vers la droite), elle est sélectionnée. Le dernier appel récursif, C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , 11, 11), retourne . L’ensemble d’activités sélectionnées
résultant est {a1 , a4 , a8 , a11 }.
16.1
Un problème de choix d’activités
369
En supposant que les activités ont déjà été triées par heures de fin, la durée d’exécution de l’appel C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , 0, n) est Q(n), ce que l’on
peut justifier comme suit. Sur l’ensemble des appels récursifs, chaque activité est
examinée une fois et une seule dans le test de la boucle tant que en ligne 2. En
particulier, l’activité ak est examinée dans le dernier appel effectué dans lequel on a
i < k.
e) Un algorithme glouton itératif
Il est facile de convertir notre procédure récursive en procédure itérative. La procédure C HOIX -D’ACTIVITÉS -R ÉCURSIF est presque « récursive terminale » (voir
problème 7.4) : elle se termine par un appel récursif à elle-même, suivi d’une opération d’union. C’est généralement un travail immédiat que de transformer une Procédure récursive terminale en une variante itérative ; d’ailleurs, certains compilateurs le
font d’eux-mêmes. Telle qu’elle est écrite, C HOIX -D’ACTIVITÉS -R ÉCURSIF fonctionne pour les sous-problèmes Si,n+1 , c’est-à-dire pour les sous-problèmes qui sont
constitués des dernières activités à finir.
La procédure C HOIX -D’ACTIVITÉS -G LOUTON est une version itérative de
C HOIX -D’ACTIVITÉS -R ÉCURSIF. Elle suppose, elle aussi, que les activités données en entrée sont triées par ordre monotone croissant d’heures de fin. Elle recueille
les activités sélectionnées dans un ensemble A, et elle retourne cet ensemble quand
elle a fini.
c Dunod – La photocopie non autorisée est un délit
C HOIX -D’ACTIVITÉS -G LOUTON(s, f )
1 n ← longueur[s]
2 A ← {a1 }
3 i←1
4 pour m ← 2 à n
5
faire si sm fi
6
alors A ← A ∪ {am }
7
i←m
8 retourner A
La procédure fonctionne de la manière suivante. La variable i indexe le tout dernier
ajout à A, qui correspond à l’activité ai dans la version récursive. Comme les activités
sont traitées par ordre croissant d’heures de fin, fi est toujours l’heure de fin maximale
d’une quelconque activité de A. C’est-à-dire que
fi = max {fk : ak ∈ A} .
(16.4)
Les lignes 2–3 sélectionnent l’activité a1 , initialisent A pour qu’il contienne uniquement cette activité, puis initialisent i pour indexer cette activité. La boucle pour des
lignes 4–7 trouve l’activité qui finit le plus tôt dans Si,n+1 . La boucle traite chaque activité am à tour de rôle et ajoute am à A si elle est compatible avec toutes les activités
déjà sélectionnées ; une telle activité est la première à finir dans Si,n+1 . Pour s’assurer
370
16 • Algorithmes gloutons
que l’activité am est compatible avec chaque activité actuellement dans A, il suffit
d’après l’équation (16.4) de vérifier (ligne 5) que son heure de début sm n’est pas
antérieure à l’heure de fin fi de l’activité qui a été ajoutée à A en dernier. Si l’activité
am est compatible, alors les lignes 6–7 ajoutent am à A et affectent à i la valeur m.
L’ensemble A retourné par l’appel C HOIX -D’ACTIVITÉS -G LOUTON(s, f ) n’est autre
que l’ensemble retourné par l’appel C HOIX -D’ACTIVITÉS -R ÉCURSIF(s, f , 0, n).
À l’instar de la version récursive, C HOIX -D’ACTIVITÉS -G LOUTON ordonnance
un ensemble de n activités en un temps Q(n), à condition que les activités aient été
préalablement triés par heures de fin.
Exercices
16.1.1 Donner un algorithme de programmation dynamique pour le problème du choix
d’activités, basé sur la récurrence (16.3). L’algorithme devra calculer les tailles c[i, j] telles
qu’elles ont été définies en amont et produire le sous-ensemble de taille maximale A d’activités. On supposera que les entrées ont été triées comme dans l’équation (16.1). Comparer le
temps d’exécution de votre solution à celui de C HOIX -D’ACTIVITÉS -G LOUTON.
16.1.2 Supposez que, au lieu de sélectionner systématiquement la première activité à se terminer, on sélectionne la dernière activité à démarrer qui soit compatible avec toutes les activités précédement sélectionnées. Expliquer en quoi cette approche est un algorithme glouton
et prouver qu’elle donne une solution optimale.
16.1.3 Supposons qu’on ait un ensemble d’activités à répartir sur un grand nombre de salles
de cours. On souhaite planifier toutes les activités avec le minimum de salles de cours. Donner
un algorithme glouton efficace qui détermine quelle est l’activité qui doit avoir lieu dans
telle ou telle salle de cours. (Ce problème est aussi connu sous le nom de coloration d’un
graphe d’intervalles. On peut créer un graphe d’intervalles dont les sommets sont les activités
données et dont les arêtes relient les activités incompatibles. Trouver le nombre minimal de
couleurs pour colorier chaque sommet de telle manière que deux sommets adjacents n’aient
jamais la même couleur, cela correspond à trouver le nombre minimal de salles nécessaires
pour ordonnancer toutes les activités données.).
16.1.4 N’importe quelle approche gloutonne du problème du choix d’activités ne produit
pas toujours un ensemble de taille maximale d’activités mutuellement compatibles. Donner un exemple montrant que l’approche qui consiste à choisir l’activité ayant la durée plus
courte, parmi celles qui sont compatibles avec les activités déjà sélectionnées, ne fonctionne
pas. Faire de même pour les approches suivantes : sélection systématique de l’activité compatible qui chevauche le moins possible d’autres activités restantes ; sélection systématique
de l’activité restante compatible qui a l’heure de début la plus précoce.
16.2 ÉLÉMENTS DE LA STRATÉGIE GLOUTONNE
Un algorithme glouton détermine une solution optimale pour un problème après avoir
effectué une série de choix. Pour chaque point de décision de l’algorithme, le choix
16.2
Éléments de la stratégie gloutonne
371
qui semble le meilleur à l’instant est effectué. Cette stratégie heuristique ne produit
pas toujours une solution optimale mais, comme nous l’avons vu dans le problème
du choix d’activités, c’est parfois le cas. Cette section étudie les propriétés générales
des méthodes gloutonnes.
Le processus que nous avons suivi à la section 16.1, pour mettre au point un algorithme glouton, était un peu plus compliqué que ce qui se fait généralement. Nous
sommes passés par les étapes suivantes :
1) Détermination de la sous-structure optimale du problème.
2) Développement d’une solution récursive.
3) Démonstration que, à chaque étape de la récursivité, l’un des choix optimaux est
le choix glouton. Par conséquent, c’est toujours une décision correcte que de faire
le choix glouton.
4) Démonstration que tous les sous-problèmes qui résultent du choix glouton, sauf
un, sont vides.
5) Mise au point d’un algorithme récursif qui implémente la stratégie gloutonne.
6) Conversion de l’algorithme récursif en algorithme itératif.
c Dunod – La photocopie non autorisée est un délit
En passant par ces étapes, nous avons vu de manière très détaillée les éléments de programmation dynamique sous-jacents à un algorithme glouton. En pratique, on allège
les étapes susmentionnées quand on conçoit un algorithme glouton. On développe la
sous-structure en ayant à l’esprit qu’il faudra faire un choix glouton qui ne laissera
qu’un seul sous-problème à résoudre de manière optimale. Par exemple, dans le problème du choix d’activités, on a commencé par définir les sous-problèmes Sij , avec i
et j variant tous les deux. On a ensuite observé que, si l’on faisait toujours le choix
glouton, on pouvait obliger les sous-problèmes à être de la forme Si,n+1 .
Une autre possibilité aurait consisté à dessiner la sous-structure optimale en ayant
en tête un choix glouton. Autrement dit : on aurait pu laisser tomber le second indice
et définir des sous-problèmes de la forme Si = {ak ∈ S : fi sk }. Ensuite, on aurait
prouvé qu’un choix glouton (la première activité am à finir dans Si ), combiné avec
une solution optimale de l’ensemble Sm d’activités compatibles restant, donne une
solution optimale de Si . Plus généralement, on conçoit les algorithmes gloutons selon
les étapes suivantes :
1) Transformation du problème d’optimisation en un problème dans lequel on fait un
choix à la suite duquel on se retrouve avec un seul sous-problème à résoudre.
2) Démonstration qu’il y a toujours une solution optimale du problème initial qui fait
le choix glouton, de sorte que le choix glouton est toujours approprié.
3) Démonstration que, après avoir fait le choix glouton, on se retrouve avec un sousproblème tel que, si l’on combine une solution optimale du sous-problème et le
choix glouton que l’on a fait, on arrive à une solution optimale du problème originel.
372
16 • Algorithmes gloutons
Nous emploierons ce processus plus direct dans les sections suivantes de ce chapitre.
Il faut savoir néanmoins que, derrière chaque algorithme glouton, se cache presque
toujours une solution plus lourde à base de programmation dynamique.
Comment peut-on savoir si un algorithme glouton saura résoudre un problème
d’optimisation particulier ? C’est en général impossible, mais la propriété du choix
glouton et la sous-structure optimale sont les deux éléments clé. Si l’on peut montrer
que le problème possède ces propriétés, alors on est en bonne voir pour créer un
algorithme glouton pour ce problème.
a) Propriété du choix glouton
Le première caractéristique principale est la propriété du choix glouton : on peut
arriver à une solution globalement optimale en effectuant un choix localement optimal (glouton). Autrement dit : quand on considère le choix à faire, on fait le choix
qui paraît le meilleur pour le problème courant, sans tenir compte des résultats des
sous-problèmes.
C’est en cela que les algorithmes gloutons diffèrent de la programmation dynamique. En programmation dynamique, on fait un choix à chaque étape, mais ce
choix dépend généralement de la solution des sous-problèmes. Par conséquent, on
résout généralement les problèmes de programmation dynamique d’une manière ascendante, en partant de petits sous-problèmes pour arriver à des sous-problèmes plus
gros. Dans un algorithme glouton, on fait le choix qui semble le meilleur sur le moment, puis on résout le sous-problème qui survient une fois que le choix est fait. Le
choix effectué par un algorithme glouton peut dépendre des choix effectués jusque
là, mais ne peut pas dépendre d’un quelconque choix futur ni des solutions des sousproblèmes. Ainsi, contrairement à la programmation dynamique, qui résout les sousproblèmes de manière ascendante, une stratégie gloutonne progresse en général de
manière descendante, en faisant se succéder les choix gloutons, pour ramener itérativement chaque instance du problème à une instance plus petite.
Bien entendu, il faut démontrer qu’un choix glouton à chaque étape engendre une
solution optimale globalement, et c’est là qu’un peu d’astuce peut s’avérer utile. La
plupart du temps, comme dans le cas du théorème 16.1, la démonstration étudie une
solution globalement optimale d’un certain sous-problème. Elle montre ensuite que la
solution peut être modifiée pour utiliser le choix glouton, ce qui donnera un problème
similaire mais de taille plus petite.
La propriété du choix glouton nous procurera souvent une certaine efficacité pour
ce qui est de faire le choix dans un sous-problème. Par exemple, dans le problème
du choix d’activités, si l’on suppose que les activités sont préalablement triées par
ordre monotone croissant d’heure de fin, on n’a à traiter chaque activité qu’une seule
fois. Il advient souvent qu’un pré traitement de l’entrée ou l’emploi d’une structure
de données idoine (une file de priorité, le plus souvent) permet de faire des choix
gloutons rapidement et donc d’obtenir un algorithme efficace.
16.2
Éléments de la stratégie gloutonne
373
b) Sous-structure optimale
Un problème exhibe une sous-structure optimale si une solution optimale du problème contient les solutions optimales des sous-problèmes. Cette propriété est un
indice majeur de l’utilisabilité de la programmation dynamique ou des algorithmes
gloutons. Comme exemple de sous-structure optimale, rappelons-nous comment nous
avons démontré à la section 16.1 que, si une solution optimale du sous-problème Sij
incluait une activité ak , alors elle contenait forcément des solutions optimales pour
les sous-problèmes Sik et Skj . Partant de cette sous-structure optimale, nous avons
prouvé que, si nous savions quelle activité prendre pour ak , nous pourrions construire
une solution optimale de Sij en sélectionnant ak plus toutes les activités des solutions
optimales des sous-problèmes Sik et Skj . En nous fondant sur cette observation de la
sous-structure optimale, nous avons pu concevoir la récurrence (16.3) décrivant la
valeur d’une solution optimale.
Nous utilisons généralement une approche plus directe, concernant la sousstructure optimale, quand nous l’appliquons aux algorithmes gloutons. Comme
précédemment mentionné, nous pouvons nous permettre le luxe de supposer que
nous sommes arrivés à un sous-problème en ayant fait le choix glouton dans le
problème originel. Tout ce que nous avons à faire, c’est de prouver qu’une solution
optimale du sous-problème, combinée avec le choix glouton déjà effectué, donne
une solution optimale du problème original. Cette stratégie utilise implicitement une
récurrence sur les sous-problèmes pour prouver que faire le choix glouton à chaque
étape produit une solution optimale.
c) Algorithme glouton et programmation dynamique
c Dunod – La photocopie non autorisée est un délit
La propriété de sous-structure optimale étant exploitée à la fois par les stratégies
gloutonnes et par la programmation dynamique, on pourrait être tenté de générer
une solution par programmation dynamique là où un algorithme glouton suffirait ; on
pourrait aussi penser, à tort, qu’une solution gloutonne fonctionne là où la programmation dynamique est nécessaire. Pour illustrer les subtilités entre les deux techniques, intéressons-nous à deux variantes d’un problème classique d’optimisation.
La variante « entière » du problème du sac-à-dos est posée de la manière suivante.
Un voleur dévalisant un magasin trouve n objets ; le ième objet vaut vi euros, et pèse
wi kilogrammes, avec vi et wi entiers. Il veut que son butin ait la plus grande valeur
possible, mais ne peut pas porter plus de W kilos dans son sac-à-dos, pour un certain
entier W. Quels objets devra-t-il prendre ? (Cette variante est dite entière parce que
chaque objet doit soit être pris soit abandonné ; le voleur ne peut pas prendre une
partie d’objet ni prendre un objet plus d’une fois).
Dans la variante fractionnaire du problème du sac-à-dos, le principe est le même,
mais le voleur peut prendre des fractions d’objets, au lieu d’avoir un choix binaire
(oui ou non) pour chacun. On peut voir un objet de la variante « entière » comme un
lingot d’or, et un objet de la variante fractionnaire comme de la poussière d’or.
374
16 • Algorithmes gloutons
Les deux problèmes du sac-à-dos exhibent la propriété de sous-structure optimale. Pour le problème entier, on considère le chargement de valeur maximale pesant au plus W kilos. Si l’on retire l’objet j du sac, le chargement restant doit être
le meilleur que puisse prendre le voleur pour un poids maximum de W − wj à
partir des n − 1 objets initiaux, j étant exclus. Pour le problème fractionnaire, on
considère que si l’on retire un poids w d’un objet j dans le chargement optimal,
le reste du chargement doit être le meilleur que le voleur puisse emporter pour un
poids maximum de W − w à partir des n − 1 objets initiaux, et des wj − w kilos de
l’objet j.
Bien que les problèmes soient similaires, la variante fractionnaire peut être résolue par une stratégie gloutonne, contrairement à la variante entière. Pour résoudre le
problème fractionnaire, on doit d’abord calculer la valeur par kilo vi /wi de chaque
objet. En suivant une stratégie gloutonne, le voleur commence par prendre la plus
grande quantité possible de l’article ayant la plus grande valeur par kilo. Si cet article
ne suffit pas à remplir le sac-à-dos, il prend le plus possible de l’article ayant la plus
grande valeur par kilo suivante, et ainsi de suite, jusqu’à ce qu’il ne puisse plus rien
emporter. Ainsi, en triant les articles en fonction de leur valeur par kilo, l’algorithme
glouton s’exécute en O(n lg n). La démonstration que la variante fractionnaire du problème du sac-à-dos vérifie bien la propriété du choix glouton est laissée en exercice
(Exercice 16.2.1).
Pour comprendre pourquoi cette stratégie gloutonne ne peut pas s’appliquer à la
variante entière, considérons l’instance du problème illustrée par la figure 16.2(a). Il
existe 3 types d’articles, et le sac peut contenir 50 kilos. L’article 1 pèse 10 kilos et
vaut 60 euros. L’article 2 pèse 20 kilos et vaut 100 euros. L’article 3 pèse 30 kilos et
vaut 120 euros. Donc, la valeur par kilo de l’article 1 est de 6 euros par kilo, qui est
plus grande que celle de l’article 2 (5 euros par kilo) ou de l’article 3 (4 euros par
kilo). La stratégie gloutonne ferait donc prendre en premier l’article 1. Néanmoins,
comme on peut le voir dans l’analyse de cas de la figure 16.2(b), la solution optimale
fait prendre les articles 2 et 3, et délaisser 1. Les deux solutions possibles mettant en
jeu l’article 1 ne sont ni l’une ni l’autre optimales.
En revanche, pour la variante fractionnaire du problème, la stratégie gloutonne,
qui commence par l’article 1, aboutit à une solution optimale, comme le montre la
figure 16.2(c). Prendre l’article 1 ne résout pas la variante entière, parce que le voleur ne peut pas remplir son sac au maximum, et la place vide fait baisser la valeur
effective par kilo de son chargement. Dans la variante entière, lorsqu’on envisage de
déposer un article dans le sac-à-dos, on doit comparer la solution au sous-problème
où l’article est inclus avec celle où l’article est exclu, puis faire un choix. Formulé
de cette manière, le problème fait apparaître de nombreux sous-problèmes emboîtés — un indice d’application de la programmation dynamique, et effectivement, la
programmation dynamique peut servir à résoudre la variante entière. (Voir l’exercice 16.2.2).
16.2
Éléments de la stratégie gloutonne
article 1
20
30
30
article 3
+
50
article 2
375
+
30
20
30
20
20
10
20
+
+
10
10
+
10
sac-à-dos
(a)
(b)
(c)
Figure 16.2 La stratégie gloutonne n’est pas adaptée à la variante entière du sac-à-dos. (a) Le
voleur doit choisir un sous-ensemble parmi les trois articles montrés, dont le poids ne doit pas
excéder 50 kilos. (b) Le sous-ensemble optimal inclut les articles 2 et 3. Une solution quelconque
incluant l’article 1 n’est pas optimale, bien que l’article 1 ait la plus grande valeur par kilo. (c) Pour
la variante fractionnaire du problème du sac-à-dos, prendre les articles dans l’ordre de la plus
grande valeur par kilo aboutit à une solution optimale.
Exercices
16.2.1 Démontrer que la variante fractionnaire du problème du sac-à-dos possède la propriété du choix glouton.
16.2.2 Donner une solution par programmation dynamique à la variante entière du problème
du sac-à-dos, qui s’exécute en O(n W), où n est le nombre d’articles différents et W est le
poids maximum que le voleur peut mettre dans son sac.
c Dunod – La photocopie non autorisée est un délit
16.2.3 Supposons que dans une variante « entière » du problème du sac-à-dos, l’ordre des
articles, quand ils sont triés par poids croissants, soit le même que lorsqu’ils sont triés par
valeur décroissante. Donner un algorithme efficace pour trouver une solution optimale à cette
variante du problème du sac-à-dos, et montrer pourquoi votre algorithme est correct.
16.2.4 Le Professeur Midas conduit une voiture entre Amsterdam à Lisbonne sur l’Européenne E10. Son réservoir d’essence, quand il est plein, contient assez d’essence pour faire
n kilomètres, et sa carte lui donne les distances entre les stations-service sur la route. Le
professeur souhaite faire le moins d’arrêts possible pendant le voyage. Donner une méthode
efficace grâce à laquelle le Professeur Midas peut déterminer les stations-service où il peut
s’arêter, et démontrer que votre stratégie aboutit à une solution optimale.
16.2.5 Décrire un algorithme efficace qui, étant donné un ensemble {x1 , x2 , . . . , xn } de
points sur une droite, détermine le plus petit ensemble d’intervalles fermés de longueur 1 qui
contiennent tous les points donnés. Démontrer la validité de votre algorithme.
16.2.6 Montrer comment résoudre la variante fractionnaire du problème du sac-à-dos en
temps O(n).
16 • Algorithmes gloutons
376
16.2.7 Soient deux ensembles A et B, contenant chacun n entiers positifs. Vous pouvez choisir de réorganiser chaque ensemble comme vous l’entendez. Après la réorganisation, soit ai
le ième élément de A et soit bi le ième élément de B. Vous recevez alors une indemnité de
,n
bi
i=1 ai . Donner un algorithme qui maximise votre indemnité. Démontrer que votre algorithme maximise l’indemnité et donner son temps d’exécution.
16.3 CODAGES DE HUFFMAN
Les codages de Huffman constituent une technique largement utilisée et très efficace
pour la compression de données ; des économies de 20 % à 90 % sont courantes,
selon les caractéristiques des données à compresser. Ici, les données sont considérées comme étant une suite de caractères. L’algorithme glouton de Huffman utilise
une table contenant les fréquences d’apparition de chaque caractère pour établir une
manière optimale de représenter chaque caractère par une chaîne binaire.
Soit un fichier de 100 000 caractères qu’on souhaite conserver de manière compacte. On observe que les caractères du fichier apparaissent avec la fréquence donnée
par la figure 16.3. Autrement dit, seulement six caractères différents apparaissent, et
le caractère a apparaît 45 000 fois.
a
b
c
d
e
f
Fréquence (en milliers)
45 13 12 16
9
5
Mot de code de longueur fixe
000 001 010 011 100 101
Mot de code de longueur variable 0 101 100 111 1101 1100
Figure 16.3 Un problème de codage de caractères. Un fichier de données de 100 000 caractères
ne contient que les caractères a–f, avec les fréquences indiquées. Si on affecte à chaque caractère
un mot de code sur 3 bits, le fichier peut être codé sur 300 000 bits. A l’aide du codage à longueur
variable montré ici, le fichier peut être encodé sur 224 000 bits.
On peut représenter ce type de fichier de nombreuses façons. Considérons le problème consistant à déterminer un codage binaire des caractères (ou en abrégé codage) dans lequel chaque caractère est représenté par une chaîne binaire unique. Si
l’on utilise un codage de longueur fixe, on a besoin de 3 bits pour représenter six
caractères : a = 000, b = 001, . . . , f = 101. Cette méthode demande 300 000 bits
pour coder entièrement le fichier. Est-il possible de faire mieux ?
Un codage de longueur variable peut faire nettement mieux qu’un codage de longueur fixe, en attribuant aux caractères fréquents les mots de code courts et aux caractères moins fréquents les mots de code longs. La figure 16.3 montre ce type de
codage ; la chaîne 0 sur 1 bit représente ici a, et la chaîne 1100 sur 4 bits représente
f. Ce codage demande
(45·1 + 13·3 + 12·3 + 16·3 + 9·4 + 5·4) ·1 000 = 224 000 bits
16.3
Codages de Huffman
377
pour représenter le fichier, soit une économie d’environ 25 %. En fait, cela représente
un codage optimal pour ce fichier, comme nous le verrons.
a) Codages préfixes
Nous ne considérons ici que les codages où aucun mot de code n’est aussi préfixe
d’un autre mot du code. Ce type de codages sont dits préfixes(2) . On peut montrer
(ce que nous ne ferons pas ici) que la compression de données maximale accessible à
l’aide d’un codage de caractères peut toujours être obtenue avec un codage préfixe ;
se restreindre aux codages préfixes ne fait donc pas perdre de généralité.
L’encodage est toujours simple pour n’importe quel code de caractères binaire ; on
se contente de concaténer les mots de code qui représentent les divers caractères du
fichier. Ainsi, avec le code préfixe de longueur variable de la figure 16.3, on code le
fichier de 3 caractères abc sous la forme 0·101·100 = 0101100, où « · » désigne la
concaténation.
Les codages préfixes sont souhaitables car ils simplifient le décodage. Comme aucun mot de code n’est un préfixe d’un autre, le mot de code qui commence un fichier
encodé n’est pas ambigu. Il suffit d’identifier le premier mot de code, de le traduire
par le caractère initial, de le supprimer du fichier encodé, puis de répéter le processus
de décodage sur le reste du fichier. Dans notre exemple, la chaîne 001011101 ne peut
être interprétée que comme 0·0·101·1101, ce qui donne aabe.
c Dunod – La photocopie non autorisée est un délit
Le processus de décodage exige que le codage préfixe ait une représentation commode, de manière qu’on puisse facilement repérer le mot de code initial. Une arborescence binaire dont les feuilles sont les caractères donnés fournit ce genre de
représentation. On interprète le mot de code binaire associé à un caractère comme
étant le chemin allant de la racine à ce caractère, où 0 signifie « bifurquer vers l’enfant gauche » et 1 signifie « bifurquer vers l’enfant droite ». La figure 16.4 montre
les arborescences pour les deux codages de notre exemple. Notez que ce ne sont pas
des arborescences binaires de recherche, puisque les feuilles n’ont pas besoin d’être
triées et que les nœuds internes ne contiennent pas de clés de caractère.
Un codage optimal pour un fichier est toujours représenté par une arborescence
binaire complète, dans lequel chaque nœud qui n’est pas une feuille a deux enfants
(voir exercice 16.3.1). Le codage de longueur fixe de notre exemple n’est pas optimal
puisque son arborescence, montrée à la figure 16.4(a), n’est pas une arborescence
binaire complète : certains mots de code commencent par 10. . . , mais aucun ne commence par 11. . . . Comme on peut maintenant restreindre notre étude aux arbres binaires complets, on peut dire que, si C est l’alphabet d’où les caractères sont issus
et si toutes les fréquences de caractère sont positives, l’arborescence représentant un
codage préfixe optimal possède exactement |C| feuilles, une pour chaque lettre de
l’alphabet, et exactement |C| − 1 nœuds internes (Voir exercice B.5.3).
(2) Sans doute « codages sans préfixes » serait-il plus adapté, mais le terme « codages préfixes » est standard
dans la littérature.
16 • Algorithmes gloutons
378
100
100
0
1
0
86
0
14
1
58
0
a:45
0
c:12
55
0
28
1
b:13
1
a:45
0
14
1
d:16
0
e:9
1
25
1
f:5
0
c:12
30
1
b:13
0
f:5
(a)
0
14
1
d:16
1
e:9
(b)
Figure 16.4 Arborescences correspondant aux schémas de codage de la figure 16.3. Chaque
feuille est étiquetée avec un caractère et sa fréquence d’apparition. Chaque nœud interne est
étiqueté avec la somme des fréquences des feuilles de sa sous-arborescence. (a) L’arborescence
correspondant au codage de longueur fixe a = 000, . . . , f = 101. (b) L’arborescence correspondant au codage préfixe optimal a = 0, b = 101, . . . , f = 1100.
Étant donnée une arborescence T correspondant à un codage préfixe, il est très
simple de calculer le nombre de bits nécessaires pour encoder un fichier. Pour chaque
caractère c de l’alphabet C, soit f (c) la fréquence de c dans le fichier et soit dT (c) la
profondeur de la feuille c dans l’arbre. Notez que dT (c) est aussi la longueur du mot
de code pour le caractère c.
Le nombre de bits requis pour encoder un fichier vaut donc
f (c)dT (c) ,
(16.5)
B(T) =
c∈C
ce qu’on définit comme étant le coût de l’arborescence T.
b) Construction d’un codage de Huffman
Huffman a inventé un algorithme glouton qui construit un codage préfixe optimal
appelé codage de Huffman.
Nous savons, d’après nos observations de la section 16.2, que la vérification de
la validité de l’algorithme repose sur la propriété du choix glouton et sur la sousstructure optimale. Au lieu de démontrer d’abord que ces propriétés sont vérifiées,
nous présenterons le pseudo code en premier lieu. Cela nous permettra de clarifier la
façon dont l’algorithme fait des choix gloutons.
Dans le pseudo code suivant, on suppose que C est un ensemble de n caractères
et que chaque caractère c ∈ C est un objet possédant une fréquence définie f [c].
L’algorithme construit du bas vers le haut l’arborescence T correspondant au codage
optimal. Il commence par un ensemble de |C| feuilles et effectue une série de |C| − 1
« fusions » pour créer l’arborescence finale. Une file de priorité min F, dont les clés
sont prises dans f , permet d’identifier les deux objets les moins fréquents à fusionner.
Le résultat de la fusion de deux objets est un nouvel objet dont la fréquence est la
somme des fréquences des deux objets fusionnés.
16.3
Codages de Huffman
379
H UFFMAN(C)
1 n ← |C|
2 Q←C
3 pour i ← 1 à n − 1
4
faire allouer un nouveau nœud z
5
gauche[z] ← x ← E XTRAIRE -M IN(Q)
6
droite[z] ← y ← E XTRAIRE -M IN(Q)
7
f [z] ← f [x] + f [y]
8
I NSÉRER(Q, z)
Retourner la racine de l’arborescence.
9 retourner E XTRAIRE -M IN(Q)
Pour notre exemple, l’algorithme de Huffman se déroule comme illustré à la figure 16.5. Comme l’alphabet comprend 6 lettres, la taille initiale de la file est n = 6,
et 5 étapes de fusion sont nécessaires pour construire l’arborescence. L’arborescence
finale représente le codage préfixe optimal. Le mot de code pour une lettre est la
séquence d’étiquettes d’arc sur le chemin reliant la racine à la lettre.
(a)
f:5
14
(c)
(e)
e:9
c:12
b:13
d:16
25
d:16
a:45
(b)
c:12
1
0
1
f:5
e:9
c:12
b:13
c:12
b:13
25
0
1
c:12
b:13
0
14
e:9
0
14
0
30
f:5
a:45
a:45
1
d:16
0
1
f:5
e:9
100
(f)
1
1
30
0
55
d:16
0
25
(d)
1
a:45
14
b:13
0
0
c Dunod – La photocopie non autorisée est un délit
a:45
1
55
a:45
0
1
1
25
d:16
30
0
1
0
1
f:5
e:9
c:12
b:13
0
14
1
d:16
0
1
f:5
e:9
Figure 16.5 Les étapes de l’algorithme de Huffman pour les fréquences données dans la figure 16.3. Chaque partie montre le contenu de la file triée par ordre de fréquence croissante.
A chaque étape, les deux arborescences ayant les fréquences les plus basses sont fusionnés. Les
feuilles sont représentées par des rectangles contenant un caractère et sa fréquence. Les nœuds
internes sont représentés par des cercles contenant la somme des fréquences des enfants. Un arc
reliant un nœud interne à ses enfants est étiqueté 0 si c’est un arc vers un enfant gauche, et 1 si
c’est un arc vers un enfant droit. Le mot de code pour une lettre est la séquence d’étiquettes des
arcs du chemin reliant la racine à la feuille correspondant à cette lettre. (a) L’ensemble initial de
n = 6 nœuds, un par lettre. (b)–(e) Étapes intermédiaires. (f) L’arborescence finale.
16 • Algorithmes gloutons
380
La ligne 2 initialise la file de priorités min F avec les caractères de C. La boucle
pour des lignes 3–8 extrait plusieurs fois les deux nœuds x et y ayant les fréquences
les plus basses de la file, et les remplace dans la file par un nouveau nœud z, résultat
de leur fusion. La fréquence de z est calculée comme la somme des fréquences de x
et y à la ligne 7. Le nœud z a pour enfant gauche x et pour enfant droit y. (Cet ordre
est arbitraire ; échanger les enfants droit et gauche d’un nœud quelconque génère un
codage différent de même coût). Après n − 1 fusions, le seul nœud qui reste dans la
file, à savoir la racine de l’arborescence de codages, est retourné en ligne 9.
L’analyse du temps d’exécution de l’algorithme de Huffman suppose que F est
implémentée via un tas min binaire (voir chapitre 6). Pour un ensemble C de n caractères, l’initialisation de F à la ligne 2 peut s’effectuer en temps O(n) via la procédure
C ONSTRUIRE -TAS de la section 6.3. La boucle pour des lignes 3–8 est exécutée
exactement |n| − 1 fois, et comme chaque opération de tas prend un temps O(lg n), la
boucle contribue pour O(n lg n) au temps d’exécution. Le temps d’exécution global
de H UFFMAN est donc O(n lg n) sur un ensemble de n caractères.
c) Validité de l’algorithme de Huffman
Pour démontrer que l’algorithme glouton H UFFMAN est correct, on montrera que le
problème consistant à déterminer un codage préfixe optimal exhibe les propriétés de
choix glouton et de sous-structure optimale. Le lemme suivant montre que la propriété du choix glouton est respectée.
Lemme 16.2 Soit C un alphabet dans lequel chaque caractère c ∈ C a une fréquence
d’apparition f [c]. Soient x et y deux caractères de C ayant les fréquences les plus
basses. Il existe alors un codage préfixe optimal pour C dans lequel les mots de code
pour x et y ont la même longueur et ne diffèrent que par le dernier bit.
Démonstration : Le principe de la démonstration est de prendre l’arborescence T
représentant un codage préfixe optimal arbitraire et de le modifier pour en faire une
arborescence représentant un autre codage préfixe optimal tel que les caractères x et
y apparaissent comme des feuilles sœur de profondeur maximale dans la nouvelle
arborescence. Si on peut faire cela, alors leurs mots de code auront la même longueur
et ne différeront que par le dernier bit.
T′
T
T′′
x
y
b
y
b
c
b
c
x
c
x
y
Figure 16.6 Une illustration de l’étape principale de la démonstration du lemme 16.2. Dans l’arborescence optimale T, les feuilles b et c sont deux des feuilles les plus profondes et sont sœurs.
Les feuilles x et y sont les deux feuilles fusionnées en premier par l’algorithme de Huffman ; elles
apparaissent dans des positions arbitraires de T. Les feuilles b et x sont permutées pour donner l’arborescence T . Ensuite, les feuilles c et y sont permutées pour obtenir l’arborescence T .
Comme chaque permutation n’augmente pas le coût, l’arborescence résultant T est également
une arborescence optimale.
16.3
Codages de Huffman
381
Soient a et b les deux caractères qui sont des feuilles sœurs de profondeur maximale dans T. Sans perdre le caractère général de la démonstration, on suppose que
f [a] f [b] et f [x] f [y]. Comme f [x] et f [y] sont les deux fréquences de feuille les
plus basses, dans l’ordre, et que f [a] et f [b] sont deux fréquences arbitraires, également dans l’ordre, on a f [x] f [a] et f [y] f [b]. Comme illustré sur la figure 16.6,
on permute les positions dans T de a et x pour produire une arborescence T , puis on
permute les positions dans T de b et y pour produire une arborescence T .
D’après l’équation (16.5), la différence de coût entre T et T est
B(T) − B(T ) =
f (c)dT (c) −
f (c)dT (c)
c∈C
c∈C
=
f [x]dT (x) + f [a]dT (a) − f [x]dT (x) − f [a]dT (a)
=
f [x]dT (x) + f [a]dT (a) − f [x]dT (a) − f [a]dT (x)
=
(f [a] − f [x])(dT (a) − dT (x))
0,
car f [a] − f [x] et dT [a] − dT [x] sont tous deux positifs ou nuls. Plus précisément,
f [a] − f [x] est positif ou nul parce que x est une feuille de fréquence minimale, et
dT [a] − dT [x] est positif ou nul parce que a est une feuille de profondeur maximale dans T. De même, comme la permutation de y et b n’augmente pas le coût,
B(T ) − B(T ) est positif ou nul. Donc, B(T ) B(T), et comme T est optimal,
B(T) B(T ), ce qui implique B(T ) = B(T). Donc, T est une arborescence optimale dans laquelle x et y sont des feuilles sœur de profondeur maximale, ce qui prouve
le lemme.
❑
c Dunod – La photocopie non autorisée est un délit
Le lemme 16.2 implique que le déroulement de la construction d’une arborescence
optimale par fusions successives peut, sans perte de généralité, commencer par le
choix glouton consistant à fusionner les deux caractères ayant les fréquences les plus
faibles. Pourquoi est-ce le choix glouton ? On peut voir le coût d’une simple fusion
comme la somme des fréquences des deux éléments fusionnés. L’exercice 16.3.3
montrera que le coût total de l’arborescence construite est la somme des coûts de ses
fusions. Parmi toutes les fusions possibles à chaque étape, H UFFMAN choisit celle de
moindre coût.
Le lemme suivant montre que le problème de la construction d’un codage préfixe
optimal vérifie la propriété de sous-structure optimale.
Lemme 16.3 Soit C un alphabet donné, avec une fréquence f [c] définie pour chaque
caractère c ∈ C. Soient x et y deux caractères de C ayant la fréquence minimale. Soit
C l’alphabet C privé des caractères x, y et complété par le (nouveau) caractère z,
de sorte que C = C − {x, y} ∪ {z} ; définissons f pour C comme pour C, sauf que
f [z] = f [x] + f [y]. Soit T une arborescence représentant un code préfixe optimal pour
l’alphabet C . Alors, l’arborescence T, obtenue à partir de T en remplaçant le nœud
feuille associé à z par un nœud interne ayant x et y comme enfants, représente un
code préfixe optimal pour l’alphabet C.
16 • Algorithmes gloutons
382
Démonstration : On commence par montrer que le coût B(T) de l’arborescence
T peut être exprimé en fonction du coût B(T ) de l’arborescence T en considérant
les coûts qui le composent dans l’équation (16.5). Pour chaque c ∈ C − {x, y}, on a
dT (c) = dT (c), et donc f [c]dT (c) = f [c]dT (c). Puisque dT (x) = dT (y) = dT (z) + 1, on a
f [x]dT (x) + f [y]dT (y) = (f [x] + f [y])(dT (z) + 1)
= f [z]dT (z) + (f [x] + f [y]) ,
d’où l’on conclut que
B(T) = B(T ) + f [x] + f [y] .
ou, ce qui revient au même,
B(T ) = B(T) − f [x] − f [y] .
Nous allons maintenant prouver le lemme en raisonnant par l’absurde. Si T ne représente pas un codage préfixe optimal pour C, il existe une arborescence T tel que
B(T ) < B(T). Sans nuire à la généralité (d’après le lemme 16.2), T a x et y comme
frères. Soit T l’arborescence T dans laquelle le parent commun à x et à y a été
remplacé par une feuille z de fréquence f [z] = f [x] + f [y]. Alors
B(T )
=
B(T ) − f [x] − f [y]
< B(T) − f [x] − f [y]
=
B(T ) ,
ce qui contredit l’hypothèse que T représente un codage préfixe optimal pour C .
Donc, T représente forcément un codage préfixe optimal pour l’alphabet C.
❑
Théorème 16.4 La procédure H UFFMAN produit un codage préfixe optimal.
Démonstration : Immédiate d’après les lemmes 16.2 et 16.3.
❑
Exercices
16.3.1 Démontrer qu’une arborescence binaire qui n’est pas complète ne peut pas correspondre à un codage préfixe optimal.
16.3.2 Donner un codage de Huffman optimal pour l’ensemble de fréquences suivant, basé
sur les 8 premiers nombres de Fibonacci ?
a :1 b :1 c :2 d :3 e :5 f :8 g :13 h :21
Pouvez-vous généraliser votre réponse pour trouver le codage optimal lorsque les fréquences
sont les n premiers nombres de Fibonacci ?
16.4
Fondements théoriques des méthodes gloutonnes
383
16.3.3 Démontrer que le coût total d’une arborescence pour un codage particulier peut aussi
être calculé comme la somme, prise sur tous les nœuds internes, des fréquences combinées
des deux enfants du nœud.
16.3.4 Démontrer que, si les caractères d’un alphabet sont triés par ordre monotone décroissant de fréquences, alors il existe un codage optimal dans lequel les longueurs des mots de
code sont monotones croissantes.
16.3.5 On suppose que l’on a un codage préfixe optimal pour un ensemble
C = {0, 1, . . . , n − 1} de caractères, et l’on veut transmettre ce codage en utilisant le
minimum de bits. Montrer comment représenter un codage préfixe optimal pour C à l’aide
de
2n − 1 + n lg n
bits seulement. (Conseil : Utiliser 2n − 1 bits pour spécifier la structure de l’arborescence,
telle que découverte par un parcours de l’arborescence).
16.3.6 Généraliser l’algorithme de Huffman aux mots de codes ternaires (c’est-à-dire, aux
mots de code utilisant les symboles 0, 1 et 2), et démontrer qu’il génère des codages ternaires
optimaux.
16.3.7 Soit un fichier de données contenant une séquence de caractères 8 bits, telle que les
256 caractères apparaissent à peu près aussi souvent les uns que les autres : la fréquence de
caractère maximale vaut moins de deux fois la fréquence de caractère minimale. Prouver que,
dans ce cas, le codage de Huffman n’est pas plus efficace qu’un codage ordinaire de longueur
fixe 8 bits.
16.3.8 Montrer qu’aucun schéma de compression ne peut espérer réduire la taille d’un fichier contenant des caractères 8 bits choisis aléatoirement, ne serait-ce que d’un seul bit.
(Conseil : Comparer le nombre de fichiers avec le nombre de fichiers encodés possibles).
c Dunod – La photocopie non autorisée est un délit
16.4 FONDEMENTS THÉORIQUES
DES MÉTHODES GLOUTONNES
Il existe une élégante théorie sur les algorithmes gloutons, que nous allons ébaucher
dans cette section. Cette théorie est utile pour déterminer les situations où la méthode
gloutonne aboutit à des solutions optimales. Elle s’appuie sur des structures combinatoires connues sous le nom de « matroïdes ». Bien que cette théorie ne couvre
pas tous les cas d’application de la méthode gloutonne (par exemple, elle ne prend
pas en compte le problème du choix d’activités de la section 16.1 ni le problème du
codage de Huffman de la section 16.3), elle couvre de nombreux cas intéressants en
pratique. Par ailleurs, cette théorie est en train de se développer et de s’étendre rapidement à nombre d’applications nouvelles ; voir les notes de fin de chapitre pour les
références.
16 • Algorithmes gloutons
384
16.4.1 Matroïdes
Un matroïde est un couple M = (E, I) qui vérifie les conditions suivantes.
1) E est un ensemble fini non-vide.
2) I est une famille non vide de sous-ensembles de E, appelée sous-ensembles indépendants de E, telle que si H ∈ I et H ⊆ F, alors H ∈ I. On dit que I est
héréditaire si elle vérifie cette propriété. On remarque que l’ensemble vide ∅ est
obligatoirement membre de I.
3) Si F ∈ I, H ∈ I et |F| < |H|, alors il existe un élément x ∈ H − F tel que
F ∪ {x} ∈ I. On dit que M vérifie la propriété d’échange.
Le mot « matroïde » est dû à Hassler Whitney. Il a étudié les matroïdes matriciels,
où les éléments de E sont les lignes d’une matrice donnée et où un ensemble de
lignes est indépendant si les lignes sont linéairement indépendantes, au sens habituel
du terme. On peut facilement montrer que cette structure définit un matroïde (voir
exercice 16.4.2).
Comme autre exemple de matroïdes, considérons le matroïde graphique
MG = (EG , IG ), ainsi défini en fonction d’un graphe non orienté G = (S, A)
donné.
– L’ensemble EG est défini comme étant l’ensemble A des arêtes de G.
– Si F est un sous-ensemble de A, alors F ∈ IG si et seulement si F est acyclique.
Autrement dit, un ensemble d’arêtes est indépendant si et seulement si le sousgraphe correspondant forme une forêt.
Le matroïde graphique MG est très proche du problème de l’arbre couvrant de poids
minimale, traité en détail au chapitre 23.
Théorème 16.5 Si G = (S, A) est un graphe non orienté, alors MG = (EG , IG ) est un
matroïde.
Démonstration : Manifestement, EG = A est un ensemble fini. De plus, IG est
héréditaire, puisqu’un sous-ensemble d’une forêt est une forêt. En d’autres termes,
supprimer des arêtes dans un ensemble d’arêtes acyclique ne peut pas créer de cycles.
Il reste donc à montrer que MG vérifie la propriété d’échange. On suppose que
GF = (S, F) et GH = (S, H) sont des forêts de G et que |F| > |H|. Autrement dit, F et
H sont des ensembles acycliques d’arête et H contient plus d’arêtes que F.
Il résulte du théorème B.2 qu’une forêt ayant k arêtes contient exactement |S| − k
arbres. (Pour le démontrer autrement, on peut partir de |S| arbres, contenant chacun
un unique sommet, et d’aucune arête. Dans ce cas, chaque arête ajoutée à la forêt
réduit d’une unité le nombre d’arborescences). Donc, la forêt GF contient |S| − |F|
arbres et la forêt GH contient |S| − |H| arbres.
Comme la forêt GH possède moins d’arborescences que GF , elle doit contenir une
arborescence T dont les sommets se trouvent dans deux arborescences différentes de
la forêt GF . Par ailleurs, puisque T est connexe, il doit contenir une arête (u, v) telle que
16.4
Fondements théoriques des méthodes gloutonnes
385
les sommets u et v soient dans des arborescences différentes de la forêt GF . Comme
l’arête (u, v) relie des sommets appartenant à deux arborescences différentes de la forêt
GF , l’arête (u, v) peut être ajoutée à la forêt GF sans qu’il y ait création de cycle. Donc,
MG vérifie la propriété d’échange, ce qui complète la démonstration établissant que
MG est un matroïde.
❑
Étant donné un matroïde M = (E, I), on dit qu’un élément x ∈
/ F est une extension
de F ∈ I si x peut être ajouté à F en préservant l’indépendance ; autrement dit,
x est une extension de F si F ∪ {x} ∈ I. Considérons l’exemple d’un matroïde
graphique MG . Si F est un ensemble d’arêtes indépendant, alors l’arête e est une
extension de F si et seulement si e n’est pas dans F et si l’ajout de e à F ne crée pas
de cycle.
Si F est un sous-ensemble indépendant d’un matroïde M, on dit que F est maximal
s’il ne possède aucune extension. C’est-à-dire s’il n’est contenu dans aucun sousensemble indépendant de M plus grand. La propriété suivante est souvent utile.
Théorème 16.6 Tous les sous-ensembles indépendants maximaux d’un matroïde ont
la même taille.
Démonstration : Supposons au contraire que F soit un sous-ensemble indépendant
maximal de M et qu’il en existe un autre H, plus grand. Alors, la propriété d’échange
implique que F peut être étendu à un ensemble indépendant F ∪ {x} pour un certain
x ∈ H − F, ce qui contredit l’hypothèse que F est maximal.
❑
En guise d’illustration de ce théorème, considérons un matroïde graphique MG
pour un graphe non orienté connexe G. Tout sous-ensemble indépendant maximal de
MG doit être un arbre ayant exactement |S| − 1 arêtes, qui relie tous les sommets de
G. Une telle arborescence est appelée arborescence couvrante de G.
On dit qu’un matroïde M = (E, I) est pondéré si l’on dispose d’une fonction de
pondération w qui affecte un poids strictement positif w(x) à chaque élément x ∈ E.
La fonction de pondération w s’étend aux sous-ensembles de E par sommation :
w(x)
w(F) =
c Dunod – La photocopie non autorisée est un délit
x∈F
pour tout F ⊆ E. Par exemple, si w(e) désigne la longueur d’une arête e dans un
matroïde graphique MG , alors w(F) est la longueur totale des arêtes de l’ensemble
d’arêtes F.
16.4.2 Algorithmes gloutons sur un matroïde pondéré
De nombreux problèmes pour lesquels une approche gloutonne donne des solutions
optimales se ramènent à la recherche d’un sous-ensemble indépendant de poids maximal dans un matroïde pondéré. Autrement dit, on dispose d’un matroïde pondéré
M = (E, I), et on souhaite trouver un ensemble indépendant F ∈ I pour lequel w(F)
est maximisé. Un tel sous-ensemble indépendant à pondération maximale est appelé
sous-ensemble optimal du matroïde. Comme la pondération w(x) d’un élément x ∈ E
quelconque est positive, un sous-ensemble optimal est toujours un sous-ensemble
indépendant maximal : il est toujours utile de rendre F le plus grand possible.
16 • Algorithmes gloutons
386
Par exemple, dans le problème de l’arborescence couvrante minimum, on dispose
d’un graphe non-orienté connexe G = (S, A) et d’une fonction longueur w telle que
w(e) soit la longueur (positive) de l’arête e. (On utilise le terme « longueur » pour
désigner les pondérations initiales des arêtes dans le graphe, en réservant le terme
« poids » pour désigner les pondérations dans le matroïde associé). On demande de
trouver un sous-ensemble d’arêtes de longueur totale minimale, qui relie tous les
sommets. Pour se ramener à un problème de recherche d’un sous-ensemble optimal
d’un matroïde, considérons le matroïde pondéré MG de fonction de pondération w ,
où w (e) = w0 − w(e) et w0 est plus grand que la longueur maximale d’une arête.
Dans ce matroïde pondéré, tous les poids sont positifs et un sous-ensemble optimal
est une arborescence couvrante de longueur totale minimale dans le graphe originel.
Plus précisément, chaque sous-ensemble indépendant maximal F correspond à une
arborescence couvrante, et comme
w (F) = (|S| − 1)w0 − w(F)
pour tout sous-ensemble indépendant maximal F, un sous-ensemble indépendant qui
maximise w (F) doit minimiser w(F). Donc, tout algorithme capable de trouver un
sous-ensemble optimal F dans un matroïde arbitraire peut résoudre le problème de
l’arborescence couvrante minimum.
Le chapitre 23 donne des algorithmes adaptés au problème de l’arborescence couvrante minimum, mais ici nous proposons un algorithme glouton qui fonctionne pour
un matroïde pondéré quelconque. L’algorithme prend en entrée un matroïde pondéré M = (E, I) associé à une fonction de pondération positive w, et il retourne un
sous-ensemble optimal F. Dans notre pseudo code, on désigne les composantes de
M par E[M] et I[M], et la fonction de pondération par w. L’algorithme est glouton
parce qu’il considère chaque élément x ∈ E l’un après l’autre par ordre de poids décroissant et qu’il l’ajoute immédiatement à l’ensemble F en cours de construction si
F ∪ {x} est indépendant.
G LOUTON(M, w)
1 F←∅
2 trier E[M] par ordre de poids décroissant w
3 pour chaque x ∈ S[M], pris par ordre de poids décroissant w(x)
4
faire si F ∪ {x} ∈ I[M]
5
alors F ← F ∪ {x}
6 retourner F
Les éléments de E sont considérés l’un après l’autre, par ordre de poids décroissant.
Si l’élément x en cours de traitement peut être ajouté à F sans nuire à l’indépendance
de F, il l’est. Sinon, x est écarté. Comme, par définition d’un matroïde, l’ensemble
vide est indépendant, et comme x n’est ajouté à F que si F ∪ {x} est indépendant,
le sous-ensemble F est toujours indépendant, par récurrence. Par suite, G LOUTON
retourne toujours un sous-ensemble indépendant F. On verra dans un moment que F
est un sous-ensemble de poids maximal, et donc que F est un sous-ensemble optimal.
16.4
Fondements théoriques des méthodes gloutonnes
387
Le temps d’exécution de G LOUTON est facile à analyser. Soit n = |E|. La phase
de tri de G LOUTON prend un temps O(n lg n). La ligne 4 est exécutée exactement n
fois, une fois pour chaque élément de E. Chaque exécution de la ligne 4 impose de
vérifier si l’ensemble F ∪ {x} est ou non indépendant. Si cette vérification prend un
temps O(f (n)), l’algorithme tout entier s’exécute en O(n lg n + nf (n)).
Démontrons à présent que G LOUTON retourne un sous-ensemble optimal.
Lemme 16.7 (Les matroïdes vérifient la propriété du choix glouton) Supposons que
M = (E, I) soit un matroïde pondéré associé à la fonction de pondération w, et que E
soit trié par ordre de poids décroissant. Soit x le premier élément de E tel que {x} soit
indépendant, s’il existe un tel x. Si x existe, alors il existe un sous-ensemble optimal
F de E qui contient x.
Démonstration : Si un tel x n’existe pas, alors le seul sous-ensemble indépendant
est l’ensemble vide et la démonstration est terminée. Dans le cas contraire, soit H un
sous-ensemble non vide optimal. On suppose que x ∈
/ H ; si tel n’était pas le cas, on
ferait F = H et la démonstration s’arêterait là.
Aucun élément de H n’a un poids supérieur à w(x). Pour le voir, il suffit d’observer
que y ∈ H implique que {y} est indépendant, puisque H ∈ I et I est héréditaire.
Notre choix de x garantit donc que w(x) w(y) pour tout y ∈ H.
L’ensemble F est construit de la manière suivante. On commence avec F = {x}. Grâce
au choix de x, F est indépendant. En se servant de la propriété d’échange, on trouve
itérativement un nouvel élément de H pouvant être ajouté à F jusqu’à ce que |F| = |H|,
tout en préservant l’indépendance de F. Alors, F = H − {y} ∪ {x} pour un certain
y ∈ H, et donc
w(F) = w(H) − w(y) + w(x)
w(H) .
Comme H est optimal, F l’est forcément aussi ; et comme x ∈ F, le lemme est démontré.
❑
On montre ensuite que si un élément n’est pas choisi au départ, il ne le sera jamais.
c Dunod – La photocopie non autorisée est un délit
Lemme 16.8 Soit M = (E, I) un matroïde. Si x est un élément de E qui est une exten-
sion d’un certain sous-ensemble indépendant F de E, alors x est aussi une extension
de ∅.
Démonstration : Comme x est extension de F, on sait que F ∪ {x} est indépendant. Puisque I est héréditaire, {x} doit être indépendant. Par conséquent, x est une
extension de ∅.
❑
Corollaire 16.9 Soit M = (E, I) un matroïde. Si x est un élément de E tel que x ne soit
pas une extension de ∅, alors x n’est extension d’aucun sous-ensemble indépendant
F de E.
Démonstration : Ce corollaire est tout simplement la contraposée du lemme 16.8.
❑
16 • Algorithmes gloutons
388
Le corollaire 16.9 dit qu’un élément qui n’est pas utilisable immédiatement ne
le sera jamais. Donc, G LOUTON ne peut pas commettre d’erreur en ignorant les éléments initiaux de E qui ne sont pas des extensions de ∅, puisqu’ils ne pourront jamais
être utilisés.
Lemme 16.10 (Les matroïdes satisfont la propriété de sous-structure optimale) Soit
x le premier élément de E choisi par G LOUTON pour le matroïde pondéré M = (E, I).
Le problème restant, à savoir trouver un sous-ensemble indépendant de poids maximal contenant x, revient à trouver un sous-ensemble indépendant de poids maximal
du matroïde pondéré M = (E , I ), où
E = {y ∈ E : {x, y} ∈ I} ,
I = {H ⊆ E − {x} : H ∪ {x} ∈ I} , et
la fonction de pondération de M est celle de M, restreinte à E . (On appelle M la
contraction de M par l’élément x).
Démonstration : Si F est un sous-ensemble indépendant de poids maximal de M
contenant x, alors F = F − {x} est un sous-ensemble indépendant de M . Inversement, un sous-ensemble indépendant F de M engendre un sous-ensemble indépendant F = F ∪{x} de M. Comme nous avons dans les deux cas w(F) = w(F )+w(x), une
solution de poids maximal pour M contenant x donne une solution de poids maximal
sur M , et vice versa.
❑
Théorème 16.11 (Validité de l’algorithme glouton sur les matroïdes) Si M = (E, I)
est un matroïde pondéré de fonction de pondération w, alors l’appel G LOUTON(M, w)
retourne un sous-ensemble optimal.
Démonstration : D’après le corollaire 16.9, tous les éléments qui ont été initialement dédaignés parce qu’ils n’étaient pas des extensions de ∅ peuvent être oubliés
pour de bon. Une fois que le premier élément x est sélectionné, le lemme 16.7 implique
que G LOUTON ne se trompe pas en ajoutant x à F, puisqu’il existe un sous-ensemble
optimal contenant x. Enfin, le lemme 16.10 implique que le reste du problème peut se
ramener à trouver un sous-ensemble optimal dans le matroïde M qui est la contraction
de M par x. Après que la procédure G LOUTON a initialisé F à {x}, toutes les étapes
restantes peuvent être interprétées comme agissant sur le matroïde M = (E , I ), car
H est indépendant dans M si et seulement si H ∪ {x} est indépendant dans M, pour
tous les ensembles H ∈ I . Donc, l’action suivante de G LOUTON consistera à trouver un sous-ensemble de M indépendant de poids maximal, et globalement parlant
G LOUTON trouvera un sous-ensemble de M indépendant de poids maximal.
❑
Exercices
16.4.1 Montrer que (E, Ik ) est un matroïde, si E est un ensemble fini et Ik est l’ensemble de
tous les sous-ensembles de E de taille au plus égale à k, où k |E|.
16.5
Un problème d’ordonnancement de tâches
389
16.4.2 Étant donnée une matrice T m × n sur un certains corps (celui des réels, par
exemple), montrer que (E, I) est un matroïde, où E est l’ensemble des colonnes de T et
F ∈ I, si et seulement si les colonnes de F sont linéairement indépendantes.
16.4.3 Montrer que si (E, I) est un matroïde, alors (E, I ) est un matroïde si l’on définit
I = {F : E − F contient un certain F ∈ I maximal}. Autrement dit, les ensembles indépendants maximaux de (E , I ) ne sont autre que les compléments des ensembles indépendants maximaux de (E, I).
16.4.4 Soit E un ensemble fini et E1 , E2 , . . . , Ek une partition de E en sous-ensembles disjoints non vides de E. On définit la structure (E, I) par la condition
I = {F : |F ∩ Ei | 1 pour i = 1, 2, . . . , k}. Montrer que (E, I) est un matroïde. Autrement dit, l’ensemble de tous les ensembles F qui contiennent au plus un membre de chaque
bloc de la partition détermine les ensembles indépendants d’un matroïde.
16.4.5 Montrer comment transformer la fonction de pondération d’un problème de matroïde
pondéré, où la solution optimale souhaitée est un sous-ensemble indépendant maximal de
poids minimal, pour revenir à un problème standard de matroïde pondéré. Argumenter soigneusement la validité de la transformation.
16.5 UN PROBLÈME D’ORDONNANCEMENT DE TÂCHES
c Dunod – La photocopie non autorisée est un délit
Un problème intéressant pouvant être résolu grâce aux matroïdes est le problème de
l’ordonnancement optimal de tâches de durée unitaire sur un processeur unique, où
chaque tâche dispose d’un temps limite de fin d’exécution au delà duquel une pénalité
doit être payée. Le problème paraît compliqué, mais on peut le résoudre d’une façon
étonnamment simple à l’aide d’un algorithme glouton.
Une tâche de durée unitaire est un travail, par exemple un programme à lancer
sur un ordinateur, qui demande exactement une unité de temps pour s’exécuter. Étant
donné un ensemble fini E de tâches unitaires, un ordonnancement de E est une permutation de E spécifiant l’ordre dans lequel ces tâches doivent être effectuées. La
première tâche de l’ordonnancement commence au temps 0 et se termine au temps 1,
la deuxième commence au temps 1 et se termine au temps 2, etc.
Voici les entrées du problème de l’ordonnancement de tâches de durée unitaire,
avec dates d’échéance et pénalités, sur un processeur unique :
– un ensemble E = {1, 2, . . . , n} de n tâches de durée unitaire ;
– un ensemble de n dates d’échéance d1 , d2 , . . . , dn , tel que chaque di vérifie
1 di n et chaque tâche i est censée se terminer à la date di ;
– un ensemble de n pénalités positives w1 , w2 , . . . , wn , tel qu’une pénalité wi survient
si la tâche i n’est pas terminée à la date di .
16 • Algorithmes gloutons
390
On souhaite trouver un ordonnancement de E qui minimise le total des pénalités.
Considérons un ordonnancement donné. On dit qu’une tâche est en retard dans
cet ordonnancement si elle se termine après sa date limite. Sinon, la tâche est dite
en avance. Un ordonnancement arbitraire peut toujours être mis sous la forme en
avance d’abord, pour laquelle les tâches en avance précèdent les tâches en retard.
Pour le voir, il suffit d’observer que, si une tâche en avance ai suit une tâche en retard
aj , on peut permuter leurs positions sans affecter les caractéristiques de ai (en avance)
et de aj (en retard).
De même, nous affirmons qu’un ordonnancement arbitraire peut toujours être mis
sous forme canonique, pour laquelle les tâches en avance précèdent les tâches en
retard et les tâches en avance sont ordonnancées par ordre monotone croissant de date
d’échéance. Pour ce faire, on donne à l’ordonnancement la forme en-avance-d’abord.
Puis, tant qu’il existe deux tâches en avance ai et aj se terminant respectivement aux
dates k et k + 1 dans l’ordonnancement telles que dj < di , on permute les positions
de ai et aj . Comme la tâche aj est en avance avant la permutation, k + 1 dj . Donc,
k + 1 < di , et donc la tâche ai est encore en avance après la permutation. La tâche aj
est avancée dans l’ordonnancement, et donc elle reste en avance après la permutation.
La recherche d’un ordonnancement optimal se ramène donc à la recherche d’un
ensemble F de tâches qui doivent être en avance dans l’ordonnancement optimal.
Une fois F déterminé, on peut créer le véritable ordonnancement en énumérant les
éléments de F dans l’ordre croissant de date limite, puis en énumérant les tâches en
retard (c’est-à-dire appartenant à E − F) dans n’importe quel ordre, ce qui équivaut à
un tri canonique de l’ordonnancement optimal.
On dit qu’un ensemble F de tâches est indépendant s’il existe un ordonnancement
pour ces tâches tel qu’aucune de ces tâches ne soit en retard. Manifestement, l’ensemble des tâches en avance d’un ordonnancement forme un ensemble indépendant
de tâches. Soit I l’ensemble de tous les ensembles indépendants de tâches.
On considère le problème consistant à déterminer si un ensemble de tâches donné
F est indépendant. Pour t = 1, 2, . . . , n, soit Nt (F) le nombre de tâches de F dont
la date d’échéance est inférieure ou égale à t. Notez que N0 (F) = 0 pour tout ensemble F.
Lemme 16.12 Pour tout ensemble de tâches F, les affirmations suivantes sont équi-
valentes.
1) L’ensemble F est indépendant.
2) Pour t = 1, 2, . . . , n, on a Nt (F) t.
3) Si les tâches de F sont ordonnancées par ordre croissant de dates d’échéance,
alors aucune tâche n’est en retard.
Démonstration : Visiblement, si Nt (F) > t pour un certain t, alors il est impossible
de construire un ordonnancement sans tâches en retard pour l’ensemble F, puisqu’il
existe plus de t tâches à effectuer avant le temps t. Donc, (1) implique (2). Si (2) est
16.5
Un problème d’ordonnancement de tâches
391
satisfaite, alors (3) s’en déduit directement : il est impossible de se tromper en ordonnançant les tâches par ordre de dates d’échéance croissantes, puisque (2) implique que
la ième plus grande date d’échéance est au plus égale à i. Enfin, (3) implique (1) de
manière triviale.
❑
En s’appuyant sur la propriété 2 du lemme 16.12, on peut facilement déterminer si
un ensemble de tâches donné est ou non indépendant (voir exercice 16.5.2).
Minimiser la somme des pénalités des tâches en retard revient à maximiser la
somme des pénalités des tâches en avance. Le théorème suivant garantit donc que
l’algorithme glouton permet de trouver un ensemble indépendant A de tâches dont la
somme des pénalités est maximale.
Théorème 16.13 Si E est un ensemble de tâches de durée unitaire et avec des dates
d’échéance, et si I est l’ensemble de tous les ensembles de tâches indépendants, alors
le système (E, I) correspondant est un matroïde.
Démonstration : Tout sous-ensemble d’un ensemble de tâches indépendant est obli-
c Dunod – La photocopie non autorisée est un délit
gatoirement indépendant. Pour prouver la propriété d’échange, on suppose que H et F
sont des ensembles de tâches indépendants et que |H| > |F|. Soit k le plus grand t tel
que Nt (H) Nt (F). (Une telle valeur de t existe, vu que N0 (F) = N0 (H) = 0.) Puisque
Nn (H) = |H| et Nn (F) = |F| mais que |H| > |F|, on doit avoir k < n et Nj (H) > Nj (F)
pour tout j de l’intervalle k + 1 j n. Donc, H contient plus de tâches ayant la date
d’échéance k + 1 que F. Soit ai une tâche de H − F ayant la date d’échéance k + 1. Soit
F = F ∪ {ai }.
On va montrer que F doit être indépendant en utilisant la propriété 2 du lemme 16.12.
Pour 0 t k, on a Nt (F ) = Nt (F) t, puisque F est indépendant. Pour k < t n,
on a Nt (F ) Nt (H) t, puisque H est indépendant. Donc F est indépendant, ce qui
termine la démonstration que (E, I) est un matroïde.
❑
D’après le théorème 16.11, on peut utiliser un algorithme glouton pour trouver
un ensemble de tâches F indépendant et de poids maximal. On peut alors créer un
ordonnancement optimal en plaçant les tâches de F comme tâches en avance pour
cet ordonnancement. Cette méthode est un algorithme efficace pour ordonnancer des
tâches de durée unitaire, avec dates d’échéance et pénalités sur un processeur unique.
Le temps d’exécution est O(n2 ) avec G LOUTON, puisque chacun des O(n) tests d’indépendance effectués par l’algorithme prend un temps O(n) (voir exercice 16.4). Une
implémentation plus rapide est donnée dans le problème 16.5.6.
di
wi
1
2
3
Tâche
4
5
6
7
4
70
2
60
4
50
3
40
1
30
4
20
6
10
Figure 16.7 Une instance du problème de l’ordonnancement de tâches de durée unitaire, avec
dates d’échéance et pénalités sur un processeur unique.
16 • Algorithmes gloutons
392
La figure 16.7 donne un exemple d’ordonnancement de tâches unitaires avec dates
d’échéance et pénalités sur un processeur unique. Dans cet exemple, l’algorithme
glouton choisit les tâches a1 , a2 , a3 et a4 , rejette a5 et a6 , puis accepte finalement la
tâche a7 . L’ordonnancement optimal final est
a2 , a4 , a1 , a3 , a7 , a5 , a6 ,
pour une pénalité totale de w5 + w6 = 50.
Exercices
16.5.1 Résoudre l’instance du problème d’ordonnancement de la figure 16.7, mais en remplaçant chaque pénalité wi par 80 − wi .
16.5.2 Montrer comment utiliser la propriété 2 du lemme 16.12 pour déterminer en temps
O(|F|) si un ensemble donné de tâches F est ou non indépendant.
PROBLÈMES
16.1. Petite monnaie
On considère le problème consistant à rendre n cents en monnaie, en utilisant le moins
de pièces possible. On supposera que chaque pièce a une valeur entière.
a. Décrire un algorithme glouton permettant de rendre la monnaie en utilisant des
pièces de cinquante, vingt, dix, cinq et un cents. Démontrer que votre algorithme
aboutit à une solution optimale.
b. On suppose que les pièces disponibles pour rendre la monnaie ont des valeurs qui
sont des puissances de c, soit p0 , p1 , . . . , pk , où p > 1 et k 1 sont deux entiers.
Montrer que l’algorithme glouton aboutit toujours à une solution optimale.
c. Donner un ensemble de valeurs de pièces pour lequel l’algorithme glouton ne
donnera pas de solution optimale. Votre ensemble doit inclure une pièce de 1 cent
de façon qu’il y ait une solution pour chaque valeur de n.
d. Donner un algorithme à temps O(nk) qui rende la monnaie pour n’importe quel
ensemble de k valeurs différentes de pièce, en supposant que l’une des pièces est
1 cent.
16.2. Ordonnancement minimisant la durée moyenne d’exécution
On suppose que l’on a un ensemble S = {a1 , a2 , . . . , an } de tâches, où la tâche
ai exige pi unités de temps de traitement en tout. On a un ordinateur pour exécuter
ces tâches, mais il ne peut exécuter qu’une tâche à la fois. Soit ci la date de fin
d’exécution de la tâche ai , c’est-à-dire l’instant auquel se termine le traitement de
la tâche ai . L’objectif ici est de minimiser la moyenne des dates de fin d’exécution,
Problèmes
393
c’est-à-dire de minimiser (1/n) ni=1 ci . Par exemple, supposez qu’il y a deux tâches,
a1 et a2 avec p1 = 3 et p2 = 5, et considérez l’ordonnancement dans lequel a2 est
exécutée en premier, suivie de a1 . Alors, c2 = 5, c1 = 8, et la durée d’exécution
moyenne est (5 + 8)/2 = 6.5.
a. Donner un algorithme qui ordonnance les tâches de façon à minimiser la durée
d’exécution moyenne. Chaque tâche doit être exécutée de façon non préemptive :
une fois démarrée ai , elle continue à s’exécuter pendant pi unités de temps. Prouver
que votre algorithme minimise la durée moyenne des dates de fin d’exécution et
donner la complexité de votre algorithme.
b. On suppose maintenant que les tâches ne sont pas toutes disponibles immédiatement : chaque tâche a une date de disponibilité ri avant laquelle elle ne peut pas
être traitée. On suppose aussi que l’on autorise la préemption : une tâche peut être
mise en sommeil, puis repartir ultérieurement. Par exemple, la tâche ai ayant la
durée de traitement pi = 6 peut commencer son exécution à la date 1, être préemptée à la date 4, reprendre son exécution à la date 10, être préemptée de nouveau
à la date 11, repartir à la date 13 et enfin s’achever à la date 15. La tâche ai s’est
exécuté en tout pendant 6 unités de temps, mais sa durée d’exécution a été divisée en trois tranches. On dira que la durée d’exécution de ai est 15. Donner un
algorithme qui planifie les tâches de façon à minimiser la durée moyennes des
dates de fin d’exécution dans le cadre de ce nouveau scénario. Prouver que votre
algorithme minimise la durée moyenne des dates de fin d’exécution et donner son
temps d’exécution.
c Dunod – La photocopie non autorisée est un délit
16.3. Sous-graphes acycliques
a. Soit G = (S, A) un graphe non-orienté. A l’aide de la définition d’un matroïde,
montrer que (A, I) est un matroïde, où F ∈ I si et seulement si F est un sousensemble acyclique de A.
b. La matrice d’incidence d’un graphe non orienté G = (S, A) est une matrice M
de dimension |S| × |A| telle que Mve = 1 si l’arête e est incidente au sommet
v, et Mve = 0 sinon. Montrer qu’un ensemble de colonnes de M est linéairement
indépendant sur le corps des entiers modulo 2 si et seulement si l’ensemble correspondant d’arête est acyclique. Ensuite, se servir du résultat de l’exercice 16.4.2
pour fournir une autre démonstration que le (A, I) de la partie (a) est un matroïde.
c. On suppose qu’une pondération positive w(e) est associée à chaque arête d’un
graphe non-orienté G = (S, A). Donner un algorithme efficace permettant de trouver un sous-ensemble acyclique de A de poids total maximal.
d. Soit G = (S, A) un graphe orienté arbitraire, et soit (A, I) défini de manière que
F ∈ I si et seulement si F ne contient pas de circuit. Donner un exemple de graphe
orienté G tel que le système associé (A, I) ne soit pas un matroïde. Spécifier quelle
est celles des conditions de définition d’un matroïde qui n’est pas satisfaite.
e. La matrice d’incidence d’un graphe orienté G = (S, A) est une matrice M de
dimension |S|×|A| telle que Mve = −1 si l’arc e part du sommet v, Mve = 1 si l’arc e
16 • Algorithmes gloutons
394
arrive au sommet v, et Mve = 0 sinon. Montrer que, si un ensemble de colonnes de
M est linéairement indépendant, alors l’ensemble d’arcs correspondant ne contient
pas de circuit.
f. L’exercice 16.4.2 nous dit que l’ensemble des ensembles de colonnes linéairement indépendants d’une matrice M forme un matroïde. Expliquer soigneusement
pourquoi les résultats des parties (d) et (e) ne sont pas contradictoires. Comment
se fait-il qu’il n’y ait pas de correspondance parfaite entre la notion d’ensemble
d’arcs ne formant pas de circuit et la notion d’ensemble linéairement indépendant
associé de colonnes de la matrice d’incidences ?
16.4. Variantes du problème de l’ordonnancement
Considérons l’algorithme suivant pour résoudre le problème de la section 16.5 consistant à ordonnancer des tâches unitaires avec dates d’échéance et pénalités. On admet
qu’au départ les n intervalles de temps sont tous vides, l’intervalle de temps i étant
l’intervalle de longueur unitaire qui se termine au temps i. On considère les tâches par
ordre monotone décroissant des pénalités. Quand on traite la tâche aj , s’il existe des
intervalles temporels non encore affectés qui sont situés à ou avant la date d’échéance
dj de aj , on affecte aj au dernier de ces intervalles, ce qui a pour effet d’initialiser l’intervalle. S’il n’existe plus d’intervalle de ce type, on affecte la tâche aj au dernier des
intervalles présentement non affectés.
a. Dire pourquoi cet algorithme donne toujours une réponse optimale.
b. Utiliser la forêt d’ensembles disjoints rapide de la section 21.3 pour implémenter l’algorithme efficacement. On suppose que l’ensemble des tâches en entrée est
déjà trié par ordre monotone décroissant de pénalité. Analyser le temps d’exécution de votre implémentation.
NOTES
On trouvera beaucoup plus de données sur les algorithmes gloutons et les matroïdes dans
Lawler [196], ainsi que dans Papadimitriou et Steiglitz [237].
L’algorithme glouton apparut d’abord dans la littérature de l’optimisation combinatoire en
1971, dans un article de Edmonds [85], bien que la théorie des matroïdes remonte à un article
paru en 1935 et dû à Whitney [314].
Notre démonstration de la validité de l’algorithme glouton pour le problème du choix
d’activités s’inspire de celle de Gavril [112]. Le problème de l’ordonnancement de tâches est
étudié par Lawler [196], par Horowitz et Sahni [157], ainsi que par Brassard et Bratley [47].
Les codages de Huffman furent inventés en 1952 [162] ; Lelewer et Hirschberg [200]
proposent un survol des techniques de compression de données parues avant 1987.
Une extension de la théorie des matroïdes, à savoir la théorie des « gloutonoïdes » fut
initiée par Korte et Lovász [189, 190, 191, 192], qui ont largement généralisé la théorie présentée ici.
Chapitre 17
Analyse amortie
c Dunod – La photocopie non autorisée est un délit
Dans une analyse amortie, le temps requis pour effectuer une suite d’opérations sur
une structure de données est une moyenne sur l’ensemble des opérations effectuées.
L’analyse amortie permet de montrer que le coût moyen d’une opération est faible
si l’on établit sa moyenne sur une suite d’opérations, même si l’opération prise individuellement est coûteuse. L’analyse amortie diffère de l’analyse du cas moyen au
sens où on ne fait pas appel aux probabilités : une analyse amortie garantit les performances moyennes de chaque opération dans le cas le plus défavorable.
Les trois premières sections de ce chapitre couvrent les trois techniques les plus
communément utilisées en analyse amortie. La section 17.1 commence par la méthode de l’agrégat, dans laquelle on détermine un majorant T(n) sur le coût total
d’une suite de n opérations. Le coût moyen par opération est alors T(n)/n. On prend
comme coût amorti par opération le coût moyen, de façon que toutes les opérations
aient le même coût amorti.
La section 17.2 couvre la méthode comptable, dans laquelle on détermine un coût
amorti pour chaque opération. Quand il existe plus d’un type d’opération, chacun peut
avoir un coût amorti différent. La méthode comptable surfacture certaines opérations
au début de la séquence, cette surfacturation servant d’« arrhes » pour des objets
particuliers de la structure de données. Le crédit sera utilisé plus tard dans la séquence
pour payer des opérations qui sont sous-facturées.
La section 17.3 étudie la méthode du potentiel, qui ressemble à la méthode comptable par le fait qu’on détermine le coût amorti de chaque opération, et qu’on peut
396
17 • Analyse amortie
surfacturer certaines opérations au début pour compenser des sous-facturations ultérieures. La méthode du potentiel gère le crédit comme « énergie potentielle » de
la structure de données globale, au lieu de l’associer à des objets individuels de la
structure de données.
Nous utiliserons deux exemples pour étudier ces trois modèles. Le premier est une
pile à laquelle on ajoute l’opération M ULTIDÉP, qui dépile plusieurs objets à la fois.
Le second est un compteur binaire qui compte à partir de 0 au moyen de la seule
opération I NCRÉMENTER.
En lisant ce chapitre, gardez à l’esprit que les charges affectées lors d’une analyse
amortie n’ont de sens que pour l’analyse elle-même. Elles n’ont pas à apparaître dans
le code. Si, par exemple, un crédit est affecté à un objet x par la méthode comptable,
il est inutile d’affecter la quantité appropriée à un attribut crédit[x] dans le code.
Le point de vue acquis en effectuant une analyse amortie sur une structure de données particulière peut aider à optimiser la conception. À la section 17.4, par exemple,
nous utiliserons la méthode du potentiel pour analyser une table capable de s’étendre
ou de se contracter dynamiquement.
17.1 MÉTHODE DE L’AGRÉGAT
Dans la méthode de l’agrégat on montre que, pour tout n, une suite de n opérations
prend le temps total T(n) dans le cas le plus défavorable. Dans le cas le plus défavorable le coût moyen, ou coût amorti, par opération est donc T(n)/n. Notez que
ce coût amorti s’applique à chaque opération, même quand il existe plusieurs types
d’opérations dans la séquence. Les deux autres méthodes que nous étudierons pourront affecter des coûts amortis différents aux différents types d’opérations.
a) Opérations de pile
Dans notre premier exemple d’analyse via méthode de l’agrégat, on analyse des piles
qui ont été étendues avec une nouvelle opération. La section 10.1 présentait les deux
opérations fondamentales de pile qui prenaient chacune un temps O(1) :
E MPILER(S, x) empile l’objet x sur la pile S.
D ÉPILER(S) dépile le sommet de la pile S et retourne l’objet dépilé.
Comme chacune de ces opérations s’exécute en O(1), considérons qu’elles ont toutes
les deux un coût égal à 1. Le coût total d’une suite de n opérations E MPILER et
D ÉPILER vaut donc n, et le temps d’exécution réel pour n opérations est donc Q(n).
Ajoutons l’opération M ULTIDÉP(S, k), qui retire les k premiers objets du sommet
de la pile S, ou dépile la pile toute entière si elle contient moins de k objets. Dans
le pseudo code suivant, l’opération P ILE -V IDE retourne VRAI s’il n’y a plus aucun
objet sur la pile, et FAUX sinon.
17.1
Méthode de l’agrégat
397
M ULTIDÉP(S, k)
1 tant que pas P ILE -V IDE(S) et k fi 0
2
faire D ÉPILER(S)
3
k ←k−1
La figure 17.1 montre un exemple de l’action de M ULTIDÉP.
sommet
23
17
6
39
10
47
(a)
sommet
10
47
(b)
(c)
Figure 17.1 L’action de M ULTIDÉP sur une pile S, montrée dans sa configuration de départ en
(a). Les 4 objets du sommet sont dépilés par M ULTIDÉP(S, 4), dont le résultat apparaît en (b).
L’opération suivante est M ULTIDÉP(S, 7), qui vide la pile (partie (c)), puisqu’il reste moins de 7
objets.
c Dunod – La photocopie non autorisée est un délit
Quel est le temps d’exécution de M ULTIDÉP(S, k) sur une pile de s objets ? Le
temps d’exécution réel est linéaire par rapport au nombre d’opérations D ÉPILER effectivement exécutées, et il suffit donc d’analyser M ULTIDÉP en fonction des coûts
abstraits de 1 attribués à E MPILER et D ÉPILER. Le nombre d’itérations de la boucle
tant que est le nombre min(s, k) d’objets retirés de la pile. Pour chaque itération de
la boucle, on fait un appel à D ÉPILER à la ligne 2. Le coût total de M ULTIDÉP est
donc min(s, k), et le temps d’exécution réel est une fonction linéaire de ce coût.
Analysons une suite de n opérations E MPILER, D ÉPILER, et M ULTIDÉP sur une
pile initialement vide. Le coût dans le cas le plus défavorable d’une opération M UL TIDÉP dans la séquence est O(n), puisque la taille de la pile est au plus égale à n.
Le coût dans le cas le plus défavorable d’une opération de pile quelconque est donc
O(n), et une suite de n opérations coûte O(n2 ), puisqu’on pourrait avoir O(n) opérations M ULTIDÉP coûtant chacune O(n). Bien que cette analyse soit correcte, le
résultat O(n2 ), obtenu en considérant le coût le plus défavorable de chaque opération
individuelle, n’est pas assez fin.
Grâce à la méthode d’analyse par agrégat, on peut obtenir un meilleure majorant,
qui prend en compte globalement la suite des n opérations. En fait, bien qu’une seule
opération M ULTIDÉP soit potentiellement coûteuse, une suite de n opérations E M PILER , D ÉPILER et M ULTIDÉP sur une pile initialement vide peut coûter au plus
O(n). Pourquoi ? Chaque objet peut être dépilé au plus une fois pour chaque empilement de ce même objet. Donc, le nombre de fois que D ÉPILER peut être appelée
sur une pile non vide, y compris les appels effectués à l’intérieur de M ULTIDÉP, vaut
au plus le nombre d’opérations E MPILER, qui lui-même vaut au plus n. Pour une
398
17 • Analyse amortie
valeur quelconque de n, n’importe quelle suite de n opérations E MPILER, D ÉPILER
et M ULTIDÉP prendra donc au total un temps O(n). Le coût moyen d’une opération
est O(n)/n = O(1). Dans l’analyse de l’agrégat, chaque opération se voit affecter du
même coût amorti, égal au coût moyen. Dans cet exemple, chacune des trois opérations de pile a donc un coût amorti O(1).
Insistons encore sur le fait que, bien que nous ayons simplement montré que le
coût moyen, et donc le temps d’exécution, d’une opération de pile était O(1), nous
n’avons fait intervenir aucun raisonnement probabiliste. Nous avons en fait établi une
borne O(n) dans le cas le plus défavorable pour une suite de n opérations. La division
de ce coût total par n a donné le coût moyen, c’est-à-dire le coût amorti par opération.
b) Incrémentation d’un compteur binaire
Un autre exemple de la méthode de l’agrégat est illustré par le problème consistant à implémenter un compteur binaire sur k bits qui compte positivement à partir
de 0. On utilise pour représenter ce compteur un tableau A[0 . . k − 1] de bits, où
longueur[A] = k. Un nombre binaire x qui est stocké dans le compteur a son bit
d’ordre inférieur dans A[0] et son bit d’ordre supérieur dans A[k − 1], de manière que
i
x = k−1
i=0 A[i]·2 . Au départ, x = 0, et donc A[i] = 0 pour i = 0, 1, . . . , k − 1.
Pour ajouter 1 (modulo 2k ) à la valeur du compteur, on utilise la procédure suivante.
I NCRÉMENTER(A)
1 i←0
2 tant que i < longueur[A] et A[i] = 1
3
faire A[i] ← 0
4
i←i+1
5 si i < longueur[A]
6
alors A[i] ← 1
La figure 17.2 montre ce qui arrive à un compteur binaire quand il est incrémenté
16 fois, en commençant à la valeur 0 et en finissant par la valeur 16. Au début de
chaque itération de la boucle tant que des lignes 2–4, on souhaite ajouter un 1 à la
position i. Si A[i] = 1, alors l’addition d’un 1 fait basculer à 0 le bit i et génère une
retenue de 1, à ajouter à la position i + 1 lors de la prochaine itération de la boucle.
Sinon, la boucle se termine ; ensuite, si i < k, on sait que A[i] = 0, et donc l’ajout
d’un 1 à la position i, qui fait passer le 0 à 1, est pris en charge par la ligne 6. Le
coût de chaque opération I NCRÉMENTER est linéaire par rapport au nombre de bits
basculés.
Comme avec l’exemple de la pile, une analyse rapide fournit une borne correcte
mais pas assez fine. Une exécution individuelle de I NCRÉMENTER prend un temps
Q(k) dans le cas le plus défavorable, celui où le tableau A ne contient que des 1. Donc,
une séquence de n opérations I NCRÉMENTER sur un compteur initialement nul prend
un temps O(nk) dans le cas le plus défavorable.
Méthode de l’agrégat
399
Compteur
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
A[
7
A[ ]
6]
A[
5
A[ ]
4
A[ ]
3]
A[
2
A[ ]
1]
A[
0]
17.1
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
0
0
0
0
0
0
0
0
1
1
1
1
1
1
1
1
0
0
0
0
0
1
1
1
1
0
0
0
0
1
1
1
1
0
0
0
1
1
0
0
1
1
0
0
1
1
0
0
1
1
0
0
1
0
1
0
1
0
1
0
1
0
1
0
1
0
1
0
Total
coût
0
1
3
4
7
8
10
11
15
16
18
19
22
23
25
26
31
c Dunod – La photocopie non autorisée est un délit
Figure 17.2 Le comportement d’un compteur binaire sur 8 bits quand sa valeur passe de 0 à 16
après une suite de 16 opérations I NCRÉMENTER. Les bits qui sont basculés pour atteindre la prochaine valeur sont en gris. Le coût d’exécution des basculements de bit est donné à droite. Notez
que le coût total ne vaut jamais plus de deux fois le nombre total d’opérations I NCRÉMENTER.
On peut affiner notre analyse pour établir un coût de O(n), dans le pire des cas, pour
une suite de n opérations I NCRÉMENTER en observant que tous les bits ne basculent
pas chaque fois que I NCRÉMENTER est appelée. Comme le montre la figure 17.2,
A[0] bascule à chaque appel de I NCRÉMENTER. Le deuxième bit de poids le plus fort,
A[1], ne bascule qu’une fois sur deux : une suite de n opérations I NCRÉMENTER sur
un compteur initialisé à zéro fait basculer A[1] n/2 fois. De même, le bit A[2] ne
bascule qu’une fois sur quatre, c’est-à-dire n/4 dans une séquence de n opérations
I NCRÉMENTER. En général, pour i = 0, 1, . . . , k − 1, le bit A[i] bascule n/2i fois
dans une séquence de n opérations I NCRÉMENTER sur un compteur initialisé à zéro.
Pour i k, le bit A[i] ne bascule jamais. Le nombre total de basculements dans la
séquence est donc
k−1 - .
∞
1
n
<
n
i
2
2i
i=0
i=0
=
2n ,
d’après l’équation (A.6). Le temps d’exécution, dans le cas le plus défavorable,
d’une suite de n opérations I NCRÉMENTER sur un compteur initialisé à zéro est donc
O(n). Le coût moyen de chaque opération, et donc le coût amorti par opération, est
O(n)/n = O(1).
17 • Analyse amortie
400
Exercices
17.1.1 Si l’on ajoutait aux opérations de pile une opération M ULTIEMP qui empile k éléments, est-ce que le coût amorti des opérations de pile aurait encore une borne O(1) ?
17.1.2 Montrer que, si l’on ajoutait une opération D ÉCRÉMENTER à l’exemple du compteur
sur k bits, n opérations pourraient coûter jusqu’à Q(nk) de temps.
17.1.3 Une suite de n opérations est effectuée sur une structure de données. La ième opération coûte i si i est une puissance exacte de 2, et 1 sinon. Utiliser la méthode de l’agrégat pour
déterminer le coût amorti par opération.
17.2 MÉTHODE COMPTABLE
Dans la méthode comptable on affecte des coûts différents à des opérations différentes, certaines opérations recevant un coût supérieur ou inférieur à leur coût réel.
Le coût attribué à une opération est son coût amorti. Lorsque le coût amorti d’une
opération excède son coût réel, la différence est affectée à des objets spécifiques de la
structure de données, sous la forme de crédit. Ce crédit pourra servir plus tard à aider
à payer des opérations dont le coût amorti est inférieur au coût réel. On peut donc
voir le coût amorti d’une opération comme une combinaison de coût réel et de crédit
déposé ou utilisé. Cela est très différent de la méthode de l’agrégat, pour laquelle
toutes les opérations ont le même coût amorti.
Il faut choisir soigneusement les coûts amortis des opérations. Si l’on veut montrer,
via analyse par la méthode comptable, que, dans le cas le plus défavorable, le coût
moyen par opération est réduit, alors le coût amorti total d’une séquence d’opérations
doit être un majorant du coût réel total de la séquence. Par ailleurs, comme pour la
méthode de l’agrégat, cette relation doit être valable pour n’importe quelle séquence
d’opérations. Si l’on note ci le coût réel de la ième opération et ci le coût amorti de la
ième opération, il faut que
n
n
ci
(17.1)
ci i=1
i=1
pour toutes les séquences de n opérations. Le crédit total stocké dans la structure
de
la différence entre le coût amorti total et le coût réel total, soit
ndonnéesest
n
−
c
c
i
i=1
i=1 i . D’après l’inégalité (17.1), le crédit total associé à la structure
de données doit être en permanence non négatif. Si le crédit total pouvait devenir
négatif (résultat d’une sous-facturation des premières opérations de la séquence avec
promesse de réapprovisionner le compte ultérieurement), alors les coûts amortis
totaux générés à ce moment-là seraient en-dessous des coûts réels totaux ; pour la
séquence d’opérations effectuées jusqu’alors, le coût amorti total ne serait pas un
majorant du coût réel total. Il faut donc prendre garde à ce que le crédit total stocké
dans la structure ne devienne jamais négatif.
17.2
Méthode comptable
401
a) Opérations de pile
Pour illustrer la méthode comptable d’analyse amortie, revenons à l’exemple de la
pile. On se souvient que les coûts réels des opérations étaient :
1,
E MPILER
1,
D ÉPILER
M ULTIEMP min(k, s) ,
où k est l’argument fourni à M ULTIEMP et s la taille de la pile au moment de l’appel.
Affectons à ces opérations les coûts amortis suivants :
E MPILER
2,
0,
D ÉPILER
M ULTIEMP 0 .
Notez que le coût amorti de M ULTIEMP est une constante (0), alors que le coût réel
est variable. Ici les trois coûts amortis sont en O(1) bien que, en général, les coûts
amortis des opérations concernées puissent différer asymptotiquement.
c Dunod – La photocopie non autorisée est un délit
Nous allons maintenant montrer qu’il est possible de payer une séquence quelconque d’opérations de pile en facturant les coûts amortis. Supposons que chaque
unité de coût soit représentée par une pièce de 1 euro. Nous commençons avec une
pile vide. Rappelez-vous l’analogie vue à la section 10.1 entre la structure de données pile et une pile d’assiettes dans une cafétéria. Lorsqu’on empile une assiette, on
utilise 1 euro pour payer le coût réel de l’empilement et il nous reste 1 euro (sur les
2 euros facturés) qu’on place sur l’assiette. A chaque instant, toute assiette de la pile
contient donc un euro de crédit.
L’euro conservé dans l’assiette est un acompte pour le coût de son dépilement.
Quand on exécute une opération D ÉPILER, on ne facture rien pour l’opération et l’on
paye son coût réel grâce au crédit contenu dans l’assiette. Pour dépiler une assiette,
on retire de l’assiette l’euro de crédit que l’on utilise pour payer le coût réel de l’opération. Ainsi, en facturant un peu plus pour l’opération E MPILER, on n’a pas besoin
de facturer quoi que ce soit pour l’opération D ÉPILER.
Par ailleurs, nous n’avons rien non plus à facturer pour les opérations M ULTIEMP.
Pour dépiler la première assiette, on prend de l’assiette l’euro de crédit qui sert à payer
le coût réel d’un dépilement. Pour dépiler la deuxième assiette, on utilise l’euro placé
sur cette assiette, et ainsi de suite. Nous avons donc, à tout instant, suffisamment facturé à l’avance pour pouvoir payer les opérations M ULTIEMP. Autrement dit, comme
chaque assiette de la pile contient 1 euro de crédit et que la pile contient toujours
un nombre non négatif d’assiette, nous sommes sûrs que le montant du crédit est
toujours non négatif. Ainsi, pour une séquence quelconque de n opérations E MPI LER , D ÉPILER et M ULTIEMP , le coût amorti total est un majorant du coût réel total.
Comme le coût amorti total est O(n), il en est de même du coût réel total.
402
17 • Analyse amortie
b) Incrémentation d’un compteur binaire
Comme autre illustration de la méthode comptable, on analyse l’opération I NCRÉ MENTER sur un compteur binaire qui commence à zéro. Comme nous l’avons observé précédemment, le temps d’exécution de cette opération est proportionnel au
nombre de bits basculés, que nous utiliserons comme coût dans cet exemple. Reprenons une pièce de 1 euro pour représenter chaque unité de coût (ici, le basculement
d’un bit).
Pour l’analyse amortie, facturons un coût amorti de 2 euros pour mettre un bit à 1.
Lorsqu’un bit est mis à 1, on utilise 1 euro (sur les deux 2 euros facturés) pour payer
la modification du bit et l’on place l’autre euro sur le bit comme crédit à utiliser
ultérieurement quand le bit repassera à 0. À chaque instant, chaque 1 du compteur
dispose d’un euro de crédit ; il est donc inutile de facturer quelque chose pour remettre
un bit à 0 (il suffira de payer la réinitialisation avec l’euro placé sur le bit).
Il est maintenant possible de déterminer le coût amorti de I NCRÉMENTER. Le coût
de réinitialisation des bits dans la boucle tant que est payé grâce aux euros placés
sur les bits à réinitialiser. À la ligne 6 de I NCRÉMENTER, un bit au plus est mis à 1 ;
le coût amorti d’une opération I NCRÉMENTER vaut donc au plus 2 euros. Le nombre
de 1 dans le compteur n’est jamais négatif, et le montant du crédit n’est donc jamais
négatif non plus. Ainsi, pour n opérations I NCRÉMENTER, le coût amorti total est
O(n), ce qui borne le coût total réel.
Exercices
17.2.1 Une séquence d’opérations est effectuée sur une pile dont la taille n’excède jamais k.
Toutes les k opérations, on fait une copie de sauvegarde de toute la pile. Montrer que le coût
de n opérations de pile, copies de sauvegarde comprises, est O(n), en assignant des coûts
amortis idoines aux diverses opérations de pile.
17.2.2 Refaire l’exercice 17.1.3 en suivant une méthode d’analyse comptable.
17.2.3 Supposons qu’on veuille non seulement incrémenter un compteur, mais aussi le remettre à zéro (c’est-à-dire, mettre tous les bits à 0). Montrer comment implémenter un compteur sous la forme d’un tableau de bits pour qu’une séquence quelconque de n opérations
I NCRÉMENTER et R ÉINITIALISER prenne un temps O(n) sur un compteur initialement à
zéro. (Conseil : Gérer un pointeur pointant vers le 1 de poids fort.)
17.3 MÉTHODE DU POTENTIEL
Au lieu de représenter le travail prépayé sous la forme d’un crédit stocké dans des
objets spécifiques de la structure de données, la méthode du potentiel représente le
travail prépayé sous la forme d’« énergie potentielle », ou plus simplement « potentiel », pouvant servir à payer les opérations futures. Le potentiel est associé à la
17.3
Méthode du potentiel
403
structure de données dans son ensemble, et non à des objets particuliers de la structure
de données.
La méthode du potentiel fonctionne comme suit. On commence avec une structure de données initiale D0 sur laquelle sont effectuées n opérations. Pour chaque
i = 1, 2, . . . , n, soit ci le coût réel de la ième opération et Di la structure de données
qui résulte de l’application de la ième opération à la structure de données Di−1 . Une
fonction potentiel F fait correspondre à chaque structure de données Di un nombre
ci
réel F(Di ), qui est le potentiel associé à la structure de données Di . Le coût amorti de la ième opération par rapport à la fonction potentiel F est défini par
ci = ci + F(Di ) − F(Di−1 ) .
(17.2)
Le coût amorti de chaque opération est donc son coût réel, plus l’augmentation de
potentiel due à l’opération. D’après l’équation (17.2), le coût amorti total des n opérations est
n
ci =
i=1
n
(ci + F(Di ) − F(Di−1 ))
i=1
=
n
ci + F(Dn ) − F(D0 ) .
(17.3)
i=1
La seconde égalité se déduit de l’équation (A.9) (page 1024), puisque les termes
F(Di ) s’annulent mutuellement.
c Dunod – La photocopie non autorisée est un délit
Si l’on peut définir unefonction potentiel F de manière que F(Dn ) F(D0 ),
ci est un majorant du coût réel total. En pratique, on
alors le coût amorti total ni=1 ne sait pas toujours combien d’opérations seront effectuées. Donc, en imposant que
F(Di ) F(D0 ) pour tout i, on garantit, comme avec la méthode comptable, que le
payement est effectué d’avance. Il est souvent commode de donner à F(D0 ) la valeur
0, et de montrer ensuite que F(Di ) 0 pour tout i. (Voir exercice 17.3.1 pour une
manière simple de gérer les cas pour lesquels F(D0 ) fi 0.)
Intuitivement, si la différence de potentiel F(Di ) − F(Di−1 ) de la ième opération
est positive, le coût amorti ci représente une surfacturation de la ième opération et le
potentiel de la structure de données croît. Si la différence de potentiel est négative,
le coût amorti représente une sous-facturation de la ième opération et le coût réel de
l’opération est payé par la diminution du potentiel.
Les coûts amortis définis par les équations (17.2) et (17.3) dépendent du choix de
la fonction potentiel F. Des fonctions potentielles différentes peuvent engendrer des
coûts amortis différents, bien qu’ils majorent toujours les coûts réels. Il faut souvent
faire des compromis en choisissant une fonction potentiel ; le choix de la fonction
potentiel optimale dépend des bornes temporelles souhaitées.
17 • Analyse amortie
404
a) Opérations de pile
Pour illustrer la méthode du potentiel, revenons une fois de plus aux opérations de
pile E MPILER, D ÉPILER et M ULTIEMP. On définit la fonction potentiel F sur une
pile comme étant le nombre d’objets de la pile. Pour la pile vide D0 initiale, on a
F(D0 ) = 0. Comme le nombre d’objet de la pile n’est jamais négatif, la pile Di qui
résulte de la ième opération a un potentiel non négatif ; d’où
F(Di ) 0
= F(D0 ) .
Le coût amorti total, par rapport à F, de n opérations représente donc un majorant du
coût réel.
Calculons maintenant les coûts amortis des diverses opérations de pile. Si la ième
opération sur une pile contenant s objets est une opération E MPILER, la différence de
potentiel est
F(Di ) − F(Di−1 ) = (s + 1) − s
= 1.
D’après l’équation (17.2), le coût amorti de E MPILER est
ci = ci + F(Di ) − F(Di−1 )
= 1+1
= 2.
Si la ième opération sur la pile est M ULTIEMP(S, k) et que k = min(k, s) objets sont
dépilés, le coût réel de l’opération est k et la différence de potentiel est
F(Di ) − F(Di−1 ) = −k .
Donc, le coût amorti de l’opération M ULTIEMP est
ci = ci + F(Di ) − F(Di−1 )
= k − k
= 0.
De même, le coût amorti d’une opération D ÉPILER ordinaire est 0.
Le coût amorti de chacune des trois opérations est O(1), et le coût amorti total
d’une séquence de n opérations est donc O(n). Comme nous avons déjà montré que
F(Di ) F(D0 ), le coût amorti total de n opérations est un majorant du coût réel
total. Le coût, dans le cas le plus défavorable, de n opérations est donc O(n).
b) Incrémentation d’un compteur binaire
Pour illustrer autrement la méthode du potentiel, reprenons l’incrémentation d’un
compteur binaire. Cette fois, on définit le potentiel du compteur après la ième opération I NCRÉMENTER comme étant égal à bi , nombre de 1 dans le compteur après la
ième opération.
17.3
Méthode du potentiel
405
Calculons le coût amorti d’une opération I NCRÉMENTER. Supposons que le ième
appel I NCRÉMENTER réinitialise ti bits. Le coût réel de l’opération est au plus ti + 1
puisque, en plus de réinitialiser ti bits, il met un bit au plus à 1. Si bi = 0, alors la
ième opération réinitialise tous les k bits, et donc bi−1 = ti = k. Si bi > 0, alors
bi = bi−1 − ti + 1. Dans l’un ou l’autre cas, bi bi−1 − ti + 1 et la différence de
potentiel est
F(Di ) − F(Di−1 ) (bi−1 − ti + 1) − bi−1
= 1 − ti .
Le coût amorti vaut donc
ci
= ci + F(Di ) − F(Di−1 )
(ti + 1) + (1 − ti )
= 2.
Si le compteur démarre à zéro, on a F(D0 ) = 0. Comme F(Di ) 0 pour tout i, le coût
amorti total d’une séquence de n opérations I NCRÉMENTER est un majorant du coût
réel total ; et le coût, dans le cas le plus défavorable, de n opérations I NCRÉMENTER
est donc O(n).
La méthode du potentiel fournit un moyen commode d’analyser le compteur même
s’il ne commence pas à zéro. On a au départ b0 bits égaux à 1 et, après n opérations
I NCRÉMENTER, on a bn bits égaux à 1, pour 0 b0 , bn k. (Rappelez-vous que k
est le nombre de bits du compteur.) L’équation (17.3) peut être réécrite sous la forme
n
n
ci =
(17.4)
ci − F(Dn ) + F(D0 ) .
i=1
i=1
On a ci 2 pour tout 1 i n. Puisque F(D0 ) = b0 et F(Dn ) = bn , le coût réel
total de n opérations I NCRÉMENTER est
n
n
ci 2 − bn + b0
i=1
i=1
c Dunod – La photocopie non autorisée est un délit
=
2n − bn + b0 .
Notez en particulier ceci : comme b0 k, tant que k = O(n), le coût réel total est O(n).
En d’autres termes, si l’on exécute au moins n = V(k) opérations I NCRÉMENTER, le
coût réel total est O(n), quelle que soit la valeur initiale du compteur.
Exercices
17.3.1 Soit une fonction potentiel F telle que F(Di ) F(D0 ) pour tout i, mais F(D0 ) fi 0.
Montrer qu’il existe une fonction potentiel F telle que F (D0 ) = 0, que F (Di ) 0 pour tout
i 1 et que les coûts amortis calculés avec F soient les mêmes que ceux calculés avec F.
17.3.2 Refaire l’exercice 17.1.3 en utilisant la méthode du potentiel.
406
17 • Analyse amortie
17.3.3 On considère un structure de données de tas min binaire classique à n éléments, qui
supporte les instructions I NSÉRER et E XTRAIRE -M IN dans un temps O(lg n) dans le cas le
plus défavorable. Donner une fonction potentiel F telle que le coût amorti de I NSÉRER soit
O(lg n) et que le coût amorti de E XTRAIRE -M IN soit O(1), et montrer qu’elle fonctionne.
17.3.4 Quel est le coût total d’exécution de n opérations de pile E MPILER, D ÉPILER et
M ULTIEMP, si l’on suppose que la pile démarre avec s0 objets et finit avec sn objets ?
17.3.5 On suppose qu’un compteur a pour valeur de départ, non pas 0 mais un nombre
contenant b bits égaux à 1. Montrer que le coût de n opérations I NCRÉMENTER est O(n) si
n = V(b). (On ne supposera pas que b est constant.)
17.3.6 Montrer comment implémenter une file avec deux piles ordinaires (exercice 10.1.6)
de telle manière que le coût amorti de chaque opération E NFILER et D ÉFILER soit O(1).
17.3.7 Imaginer une structure de données qui permette les deux opérations suivantes pour
un ensemble S d’entiers :
I NSÉRER (S, x) insère x dans l’ensemble S.
S UPPRIMER -M OITIÉ -S UPÉRIEURE (S) supprime les S/2 plus grands éléments de S.
Expliquer comment implémenter cette structure pour que toute séquence de m opérations soit
exécutée en temps O(m).
17.4 TABLES DYNAMIQUES
Dans certaines applications, on ne sait pas à l’avance le nombre d’objets qui seront
stockés dans une table. Il arrive qu’on se rende compte, après allocation d’espace
pour une table, qu’elle n’est pas suffisamment grande. La table doit alors être réallouée avec une taille plus grande, et tous les objets stockés dans la table initiale
doivent être copiés dans la nouvelle table. De même, si de nombreux objets ont été
supprimés dans une table, il peut être judicieux de réallouer une table de taille plus
petite. Dans cette section, nous étudierons ce problème d’extension et de contraction dynamique d’une table. Grâce à l’analyse amortie, nous montrerons que le coût
amorti de l’insertion et de la suppression est seulement de O(1), même si le coût
réel d’une opération est grand au moment quand elle déclenche un extension ou une
contraction. En outre, nous verrons comment garantir que l’espace inutilisé d’une
table dynamique n’excède jamais une fraction constante de l’espace total.
On suppose que la table dynamique supporte les opérations I NSÉRER -TABLE et
S UPPRIMER -TABLE. I NSÉRER -TABLE insère dans la table un élément qui occupe
une alvéole unique, c’est-à-dire un espace prévu pour contenir un élément. De même,
S UPPRIMER -TABLE retire un élément de la table, libérant ainsi une alvéole. Les détails de la méthode de structuration des données utilisée pour organiser la table sont
sans importance ; on pourrait utiliser une pile (section 10.1), un tas (chapitre 6) ou
17.4
Tables dynamiques
407
une table de hachage (chapitre 11). On pourrait également utiliser un tableau ou une
collection de tableaux pour implémenter le stockage des objets, ce que nous avions
fait à la section 10.3.
On trouvera commode d’utiliser un concept introduit dans notre analyse du hachage (chapitre 11). On définit le facteur de remplissage a(T) d’une table T non vide
comme étant le nombre d’éléments stockés dans la table, divisé par la taille (nombre
d’alvéoles) de la table. On donne à une table vide (table sans aucun élément) une
taille de 0, et on convient que son facteur de remplissage vaut 1. Si le facteur de remplissage d’une table dynamique est minoré par une constante, l’espace inutilisé de la
table n’est jamais supérieur à une fraction constante de la quantité totale d’espace.
On commencera par analyser le comportement d’une table dynamique dans laquelle seules des insertions sont effectuées. Nous considérerons ensuite le cas plus
général où à la fois des insertions et des suppressions sont autorisées.
17.4.1 Extension d’une table
c Dunod – La photocopie non autorisée est un délit
Supposons qu’un espace de stockage soit alloué pour une table sous forme d’un tableau d’alvéoles. Une table est remplie lorsque toutes les alvéoles ont été utilisées ou,
si l’on veut, quand son facteur de remplissage vaut 1.(1) Dans certains environnements
logiciels, si l’on tente d’insérer un élément dans une table pleine, on provoque systématiquement l’arrêt du programme. Nous supposerons toutefois que notre environnement de programmation, comme beaucoup d’environnements modernes, dispose
d’un système de gestion de la mémoire capable d’allouer et de libérer des blocs de
mémoire à la demande. Ainsi, quand un élément est inséré dans une table pleine, on
peut étendre cette table en allouant une nouvelle table, avec plus d’alvéoles que la
précédente, puis copier les éléments de l’ancienne vers la nouvelle. C’est parce que
l’on veut que la table réside en permanence dans un espace de mémoire d’un seul
tenant qu’il faut allouer un nouveau tableau pour la table agrandie et dupliquer dans
la nouvelle table le contenu de l’ancienne.
Une heuristique fréquente consiste à allouer une nouvelle table contenant deux
fois plus d’alvéoles que l’ancienne. Si seules des insertions sont effectuées, le facteur
de remplissage de la table est toujours au moins 1/2 et donc la quantité d’espace
gaspillée ne dépasse jamais la moitié de l’espace total alloué pour la table.
Dans le pseudo code suivant, on suppose que T est un objet représentant la table.
Le champ table[T] contient un pointeur vers le bloc de mémoire représentant la table.
Le champ num[T] contient le nombre d’éléments de la table et le champ taille[T] est
le nombre total d’alvéoles. Au départ, la table est vide : num[T] = taille[T] = 0.
(1) Dans certaines situations, comme pour une table de hachage en adressage ouvert, on peut considérer
qu’une table est remplie si son facteur de remplissage est égal à une certaine constante strictement inférieure
à 1. (Voir exercice 17.4.1.)
17 • Analyse amortie
408
I NSÉRER -TABLE(T, x)
1 si taille[T] = 0
2
alors allouer table[T] avec 1 alvéole
3
taille[T] ← 1
4 si num[T] = taille[T]
5
alors allouer 2· taille[T] alvéoles pour nouvelle-table
6
insérer tous les éléments de table[T] dans nouvelle-table
7
libérer table[T]
8
table[T] ← nouvelle-table
9
taille[T] ← 2· taille[T]
10 insérer x dans table[T]
11 num[T] ← num[T] + 1
Remarquez que nous avons ici deux procédures « d’insertion » : la procédure
I NSÉRER -TABLE elle-même, plus l’insertion élémentaire dans une table en lignes
6 et 10. On peut analyser le temps d’exécution de I NSÉRER -TABLE en fonction du
nombre d’insertions élémentaires, en affectant à chaque insertion élémentaire un
coût de 1. On suppose que le temps d’exécution réel de I NSÉRER -TABLE est linéaire
par rapport au temps d’insertion d’un élément individuel, de sorte que le surcoût dû
à l’allocation d’une table initiale en ligne 2 est constant et que le surcoût induit par
l’allocation et la libération de mémoire aux lignes 5 et 7 est dominé par le coût du
transfert des éléments en ligne 6. On appelle extension l’événement pour lequel la
clause alors des lignes 5–9 est exécutée.
Analysons une séquence de n opérations I NSÉRER -TABLE sur une table initialement vide. Quel est le coût ci de la ième opération ? S’il reste de la place dans la
table courante (ou si c’est la première opération), alors ci = 1, puisqu’on n’a besoin
d’effectuer qu’une seule insertion élémentaire à la ligne 10. Si la table courante est
pleine, en revanche, et qu’une extension a lieu, alors ci = i : le coût est 1 pour l’insertion élémentaire de la ligne 10, plus i − 1 pour les éléments qui doivent être copiés à
partir de l’ancienne table vers la nouvelle en ligne 6. Si n opérations sont effectuées,
le coût le plus défavorable d’une opération est O(n), ce qui conduit à un majorant de
O(n2 ) pour le temps d’exécution total de n opérations.
Cette borne n’est pas très fine, car le coût de l’extension de la table n’est pas
souvent atteint au cours des n opérations I NSÉRER -TABLE. Plus précisément, la ième
opération ne déclenche une extension que si i − 1 est une puissance exacte de 2. Le
coût amorti d’une opération est en fait O(1), comme on peut le montrer grâce à la
méthode de l’agrégat. Le coût de la ième opération est
ci =
i
si i − 1 est une puissance exacte de 2 ,
1 sinon .
17.4
Tables dynamiques
409
Le coût total de n opérations I NSÉRER -TABLE est donc
n
lg n
ci n +
i=1
2j
j=0
< n + 2n
=
3n ,
puisqu’il existe au plus n opérations de coût 1 et que les coûts des autres opérations forment une série géométrique. Comme le coût total de n opérations I NSÉRER TABLE est 3n, le coût amorti d’une opération individuelle est 3.
Grâce à la méthode comptable, on peut se faire une idée de la raison pour laquelle
le coût amorti d’une opération I NSÉRER -TABLE doit être 3. Intuitivement, chaque
élément paye pour 3 insertions élémentaires : sa propre insertion dans la table courante, son déplacement quand la table est étendue et le déplacement d’un autre élément qui a été placé dans la table lors de l’extension. Par exemple, supposons que la
taille de la table soit m juste après une extension. Alors, le nombre d’éléments de la
table est m/2 et la table ne contient aucun crédit. On facture trois euros pour chaque
insertion. L’insertion élémentaire qui vient juste après coûte 1 euro. Un autre euro est
placé comme crédit sur l’élément inséré. Le troisième euro est placé comme crédit
sur l’un des m/2 éléments déjà présents dans la table. Le remplissage de la table demande m/2−1 insertions supplémentaires ; et donc, jusqu’à ce que la table contienne
m éléments et soit pleine, chaque élément a un euro à verser pour sa réinsertion pendant l’extension.
La méthode du potentiel peut aussi servir à analyser une séquence de n opérations
I NSÉRER -TABLE, et nous l’utiliserons à la section 17.4.2 pour concevoir une opération S UPPRIMER -TABLE ayant également un coût amorti de O(1). On commence
par définir une fonction potentiel F qui vaut 0 juste après une extension, mais qui
augmente jusqu’à prendre la taille de la table quand la table est pleine de manière
que l’extension suivante puisse être payée par ce potentiel. La fonction
c Dunod – La photocopie non autorisée est un délit
F(T) = 2· num[T] − taille[T]
(17.5)
est une possibilité. Immédiatement après une extension, on a num[T] = taille[T]/2, et
donc F(T) = 0 comme souhaité. Juste avant une extension, on a num[T] = taille[T],
et donc F(T) = num[T] comme souhaité. La valeur initiale du potentiel est 0 et,
comme la table est toujours au moins à moitié pleine, num[T] taille[T]/2, ce qui
implique que F(T) est toujours non négative. Donc, la somme des coûts amortis de n
opérations I NSÉRER -TABLE est un majorant de la somme des coûts réels.
Pour analyser le coût amorti de la ième opération I NSÉRER -TABLE, on appelle
numi le nombre d’éléments présents dans la table après la ième opération, taillei
la taille totale de la table après la ième opération et Fi le potentiel après la ième
opération. Au départ, on a num0 = 0, taille0 = 0 et F0 = 0.
17 • Analyse amortie
410
Si la ième opération I NSÉRER -TABLE ne provoque pas d’extension, alors on a
taillei = taillei−1 et le coût amorti de l’opération est
ci = ci + Fi − Fi−1
= 1 + (2· numi − taillei ) − (2· numi−1 − taillei−1 )
= 1 + (2· numi − taillei ) − (2(numi −1) − taillei )
= 3.
Si en revanche la ième opération déclenche une extension, alors on a taillei = 2· taillei−1
et taillei−1 = numi−1 = numi −1, ce qui implique que taillei = 2·(numi −1). Le coût
amorti de l’opération est donc
ci = ci + Fi − Fi−1
= numi +(2· numi − taillei ) − (2· numi−1 − taillei−1 )
= numi +(2· numi −2·(numi −1)) − (2(numi −1) − (numi −1))
= numi +2 − (numi −1)
= 3.
La figure 17.3 montre les valeurs de numi , taillei et Fi en fonction de i. Remarquez
la façon dont l’augmentation de potentiel paie l’extension de la table.
32
taillei
24
16
numi
Φi
8
0
i
0
8
16
24
32
Figure 17.3 L’effet d’une séquence de n opérations I NSÉRER -TABLE sur le nombre numi d’éléments de la table, le nombre taillei d’alvéoles de la table et le potentiel Fi = 2 · numi − taillei , chacun étant mesuré après la ième opération. La courbe fine représente numi , la courbe en pointillés
représente taillei et la courbe épaisse représente Fi . Remarquez que, juste avant une extension,
le potentiel a atteint une valeur égale au nombre d’éléments de la table et qu’il peut donc payer
le déplacement de tous les éléments vers la nouvelle table. Ensuite, le potentiel retombe à 0 mais
augmente immédiatement de 2 lorsque l’élément qui a provoqué l’extension est inséré.
17.4
Tables dynamiques
411
17.4.2 Extension et contraction d’une table
Pour implémenter une opération S UPPRIMER -TABLE, il suffit de retirer de la table
l’élément spécifié. Toutefois, il est souvent intéressant de contracter la table lorsque
le facteur de remplissage devient trop faible, pour que l’espace gaspillé ne soit pas
trop important. La contraction d’une table ressemble à son extension : lorsque le
nombre d’éléments de la table est trop réduit, on alloue une nouvelle table, plus petite
et on copie les éléments de l’ancienne table vers la nouvelle. La libération de l’espace
de stockage de l’ancienne table peut alors être confiée au gestionnaire de mémoire.
Idéalement, on souhaiterait préserver deux propriétés :
– le facteur de remplissage de la table dynamique est minoré par une constante, et
– le coût amorti d’une opération de table est majoré par une constante.
On suppose que le coût peut être mesuré en termes d’insertions et de suppressions
élémentaires.
Une stratégie naturelle pour l’extension et la contraction consiste à doubler la taille
de la table lorsqu’un élément est inséré dans une table pleine, et à diminuer de moitié
cette taille lorsque le facteur de remplissage de la table devient inférieur à 1/2 après
une suppression. Cette stratégie garantit que le facteur de remplissage n’est jamais
inférieur à 1/2 mais, malheureusement, elle risque de beaucoup augmenter le coût
amorti d’une opération. Considérons le scénario suivant. On effectue n opérations
sur une table T, où n est une puissance exacte de 2. Les n/2 premières opérations
sont des insertions, ce qui, d’après notre précédente analyse, coûte au total Q(n). A la
fin de cette séquence d’insertions, num[T] = taille[T] = n/2. Pour les n/2 opérations
restantes, on effectue la séquence suivante :
c Dunod – La photocopie non autorisée est un délit
I, S, S, I, I, S, S, I, I, . . . ,
où I représente une insertion et S une suppression. La première insertion provoque
une extension de la table jusqu’à une taille n. Les deux suppressions suivantes provoque une contraction de la table, qui reprend une taille de n/2. Deux insertions
supplémentaires causent une nouvelle extension, et ainsi de suite. Le coût de chaque
extension ou contraction est Q(n), et il y en a Q(n). Donc, le coût total des n opérations est Q(n2 ) et le coût amorti d’une opération est Q(n).
La difficulté de cette stratégie est évidente : après une extension, on n’effectue pas
assez de suppressions pour payer une contraction. De même, après une contraction,
on ne fait pas assez d’insertions pour payer une extension.
Cette stratégie peut être améliorée en permettant au facteur de remplissage de la
table de passer sous la barre des 1/2. Plus précisément, on continue à doubler la taille
de la table quand un élément est inséré dans une table pleine ; mais on ne diminue de
moitié la taille de la table que quand une suppression provoque un remplissage de la
table inférieur à 1/4 et non à 1/2 comme précédemment. Le facteur de remplissage
de la table est donc minoré par la constante 1/4. L’idée ici est la suivante : après une
extension, le facteur de remplissage vaut 1/2. Donc, la moitié des éléments de la table
412
17 • Analyse amortie
devront être supprimés avant qu’une contraction n’ait lieu, puisque cette contraction
ne se produit que si le facteur de remplissage descend en-deçà de 1/4. De même,
après une contraction, le facteur de remplissage vaut encore 1/2. Donc les insertions
éventuelles doivent doubler le nombre d’éléments dans la table avant qu’une extension ait lieu, puisqu’elle ne se produit que lorsque le facteur de remplissage dépasse
la valeur 1.
Nous omettrons le code de S UPPRIMER -TABLE, puisqu’il est analogue à
I NSÉRER -TABLE. Par commodité, on supposera néanmoins dans l’analyse que,
si le nombre d’éléments de la table retombe à 0, l’espace de stockage de la table est
libéré. Autrement dit, si num[T] = 0, alors taille[T] = 0.
On peut maintenant utiliser la méthode du potentiel pour analyser le coût d’une
séquence de n opérations I NSÉRER -TABLE et S UPPRIMER -TABLE. Nous commençons par définir une fonction potentiel F qui vaut 0 juste après une extension ou une
contraction, et qui augmente quand le facteur de remplissage arrive à 1 ou retombe
à 1/4. Soit a(T) = num[T]/ taille[T] le facteur de remplissage d’une table non vide
T. Puisque, pour une table vide, num[T] = taille[T] = 0 et a[T] = 1, on a toujours num[T] = a(T)· taille[T], que la table soit vide ou non. Nous prendrons comme
fonction potentiel
2· num[T] − taille[T] si a(T) 1/2 ,
F(T) =
(17.6)
taille[T]/2 − num[T] si a(T) < 1/2 .
Observez que le potentiel d’une table vide est 0 et que le potentiel n’est jamais négatif. Donc, le coût amorti total d’une séquence d’opération par rapport à F est un
majorant du coût réel de la séquence.
Avant de passer à une analyse plus précise, arrêtons-nous pour observer quelques
propriétés de cette fonction potentiel. Remarquez que, lorsque le facteur de remplissage vaut 1/2, le potentiel vaut 0. Quand le facteur de remplissage vaut 1, on a
taille[T] = num[T], ce qui implique que F(T) = num[T], et donc que le potentiel
peut payer une extension si un élément est inséré. Lorsque le facteur de remplissage
vaut 1/4, on a taille[T] = 4· num[T], ce qui implique que F(T) = num[T] et donc
que le potentiel peut payer le prix d’une contraction si un élément est supprimé. La
figure 17.4 illustre le comportement du potentiel pour une séquence d’opérations.
Pour analyser une séquence de n opérations I NSÉRER -TABLE et S UPPRIMER TABLE, soient ci le coût réel de la ième opération, ci son coût amorti par rapport
à F, numi le nombre d’éléments stockés dans la table après la ième opération, taillei
la taille totale de la table après la ième opération, ai le facteur de remplissage de la
table après la ième opération et Fi le potentiel après la ième opération. Au départ,
num0 = 0, taille0 = 0, a0 = 1 et F0 = 0.
Nous commençons par le cas où la ième opération est I NSÉRER -TABLE. L’analyse
est la même que celle concernant l’extension de table vue à la section 17.4.1, si
ci de
ai−1 1/2. Que la table soit ou non en phase d’extension, le coût amorti l’opération est au plus 3. Si ai−1 < 1/2, l’opération ne peut pas provoquer l’extension
17.4
Tables dynamiques
413
32
24
taillei
16
numi
8
Φi
0
0
8
16
24
32
40
48
i
Figure 17.4 L’effet d’une séquence de n opérations I NSÉRER -TABLE et S UPPRIMER -TABLE sur le
nombre numi d’éléments de la table, le nombre taillei d’alvéoles dans la table et le potentiel
Fi =
2 · num − taille
i
i
taillei /2 − numi
si ai 1/2 ,
si ai < 1/2 ,
chacun étant mesuré après la ième opération. La courbe fine représente numi , la courbe en pointillés représente taillei et la courbe épaisse représente Fi . Notez que, juste avant une extension, le
potentiel a atteint une valeur égale au nombre d’éléments de la table et qu’il peut donc payer le
déplacement de tous les éléments vers la nouvelle table. De même, juste avant une contraction,
le potentiel a atteint une valeur égale au nombre d’éléments de la table.
de la table puisque l’extension n’a lieu que lorsque ai−1 = 1. Si l’on a aussi ai < 1/2,
le coût amorti de la ième opération est
c Dunod – La photocopie non autorisée est un délit
ci =
=
=
=
ci + Fi − Fi−1
1 + (taillei /2 − numi ) − (taillei−1 /2 − numi−1 )
1 + (taillei /2 − numi ) − (taillei /2 − (numi −1))
0.
Si ai−1 < 1/2 mais ai 1/2, alors
ci = ci + Fi − Fi−1
= 1 + (2· numi − taillei ) − (taillei−1 /2 − numi−1 )
= 1 + (2(numi−1 +1) − taillei−1 ) − (taillei−1 /2 − numi−1 )
3
= 3· numi−1 − taillei−1 +3
2
3
= 3ai−1 taillei−1 − taillei−1 +3
2
3
3
taillei−1 − taillei−1 +3
<
2
2
= 3.
Le coût amorti d’une opération I NSÉRER -TABLE est donc au plus égal à 3.
17 • Analyse amortie
414
Penchons-nous maintenant sur le cas où la ième opération est S UPPRIMER -TABLE.
Dans ce cas, numi = numi−1 −1. Si ai−1 < 1/2, il faut savoir si l’opération provoque
ou non une contraction. Si ce n’est pas le cas, taillei = taillei−1 et le coût amorti de
l’opération est
ci = ci + Fi − Fi−1
= 1 + (taillei /2 − numi ) − (taillei−1 /2 − numi−1 )
= 1 + (taillei /2 − numi ) − (taillei /2 − (numi +1))
= 2.
Si ai−1 < 1/2 et que la ième opération provoque une contraction, le coût réel de
l’opération est ci = numi +1, car on supprime un élément et on en déplace numi
éléments. On a taillei /2 = taillei−1 /4 = numi−1 = numi +1 et le coût amorti de
l’opération est
ci = ci + Fi − Fi−1
= (numi +1) + (taillei /2 − numi ) − (taillei−1 /2 − numi−1 )
= (numi +1) + ((numi +1) − numi ) − ((2· numi +2) − (numi +1))
= 1.
Quand la ième opération est S UPPRIMER -TABLE et que ai−1 1/2, le coût amorti
est également majoré par une constante. Une analyse est proposée à l’exercice 17.4.2.
En résumé, puisque le coût amorti de chaque opération est majoré par une
constante, le temps réel d’une séquence quelconque de n opérations sur une table
dynamique est O(n).
Exercices
17.4.1 On souhaite implémenter une table de hachage dynamique à adressage ouvert. Pourquoi pourrait-on vouloir considérer que la table est pleine quand son facteur de remplissage
atteint une certaine valeur a strictement inférieure à 1 ? Décrire brièvement la manière de
faire des insertions dans une table de hachage dynamique à adressage ouvert, pour qu’elles
s’exécutent de manière que la valeur moyenne du coût amorti par insertion soit O(1). Pourquoi la valeur moyenne du coût réel par insertion n’est-elle pas forcément O(1) pour toutes
les insertions ?
17.4.2 Montrer que, si la ième opération sur une table dynamique est S UPPRIMER -TABLE et
que ai−1 1/2, alors le coût amorti de l’opération par rapport à la fonction potentiel (17.6)
est majoré par une constante.
17.4.3 Supposons que, au lieu de contracter une table en diminuant sa taille de moitié quand
son facteur de remplissage passe sous la barre des 1/4, on la contracte en multipliant sa taille
par 2/3 quand son facteur de remplissage tombe en-deçà 1/3. A l’aide de la fonction potentiel
F(T) = |2· num[T] − taille[T]| ,
montrer que le coût amorti d’une opération S UPPRIMER -TABLE qui fait appel à cette stratégie
est majoré par une constante.
Problèmes
415
PROBLÈMES
17.1. Compteur binaire de permutations miroir
Le chapitre 30 examine un algorithme important appelé Transformée Rapide de Fourier ou TRF. La première étape de l’algorithme TRF effectue une permutation miroir
sur un tableau d’entrée A[0 . . n − 1] dont la longueur est n = 2k pour un certain entier non négatif k. Cette permutation échange les éléments dont les indices ont des
représentations binaires qui sont inverses l’une de l’autre.
On peut exprimer chaque indice a comme une séquence de k bits ak−1 , ak−2 , . . . ,
i
a0 , où a = k−1
i=0 ai 2 . On définit
revk (ak−1 , ak−2 , . . . , a0 ) = a0 , a1 , . . . , ak−1 ;
donc,
revk (a) =
k−1
ak−i−1 2i .
i=0
Par exemple, si n = 16 (ou, si l’on préfère, k = 4), alors revk (3) = 12, puisque la représentation sur 4 bits de 3 est 0011, ce qui donne après inversion 1100, représentation
sur 4 bits de 12.
a. Étant donnée une fonction revk qui s’exécute dans un temps Q(k), écrire un algorithme pour effectuer la permutation miroir sur un tableau de longueur n = 2k dans
un temps O(nk).
c Dunod – La photocopie non autorisée est un délit
On peut utiliser un algorithme basé sur une analyse amortie pour améliorer le temps
d’exécution de la permutation miroir. On gère un « compteur de permutations miroir »
et une procédure I NCRÉMENTER -M IROIR qui, à partir d’une valeur a du compteur de
permutations miroir, produit revk (revk (a) + 1). Si k = 4, par exemple, et que le compteur commence à 0, alors des appels successifs à I NCRÉMENTER -M IROIR produisent
la séquence
0000, 1000, 0100, 1100, 0010, 1010, . . . = 0, 8, 4, 12, 2, 10, . . .
b. Supposons que les mots de votre ordinateur contiennent des valeurs sur k bits
et que, en une unité de temps, l’ordinateur soit capable de manipuler les valeurs
binaires avec des opérations comme les décalages droite/gauche d’une quantité
arbitraire, le ET logique, le OU logique, etc. Donner une implémentation de la
procédure I NCRÉMENTER -M IROIR permettant d’effectuer la permutation miroir
d’un tableau de n éléments en un temps de O(n) au total.
c. On suppose qu’il est possible de décaler un mot à gauche ou à droite d’un seul bit
en une unité de temps. Est-il encore possible d’implémenter la permutation miroir
en O(n) ?
17 • Analyse amortie
416
17.2. Une recherche dichotomique dynamique
La recherche dichotomique dans un tableau trié consomme un temps logarithmique,
mais le temps d’insertion d’un nouvel élément est linéaire par rapport à la taille du
tableau. On peut améliorer le temps d’insertion en gérant séparément plusieurs tableaux triés.
Plus précisément, on suppose qu’on souhaite implémenter R ECHERCHER et I NSÉ sur un ensemble de n éléments. Supposons que k = lg(n + 1) et que la représentation binaire de n soit nk−1 , nk−2 , . . . , n0 . On a k tableaux triés A0 , A1 , . . . , Ak−1
où, pour i = 0, 1, . . . , k − 1, la longueur du tableau Ai est 2i . Chaque tableau est soit
plein, soit vide, selon que ni = 1 ou ni = 0. Le nombre total d’éléments contenus dans
i
les k tableaux est donc k−1
i=0 ni 2 = n. Bien que chaque tableau individuel soit trié,
il n’existe pas de relation particulière entre les éléments des différents tableaux.
RER
a. Décrire comment mettre en œuvre l’opération R ECHERCHER sur cette structure
de données. Analyser son temps d’exécution dans le cas le plus défavorable.
b. Expliquer comment insérer un nouvel élément dans cette structure de données.
Analyser son temps d’exécution dans le cas le plus défavorable, puis son temps
d’exécution amorti.
c. Étudier l’implémentation de S UPPRIMER.
17.3. Arbres équilibrés pondérés amortis
On considère un arbre binaire de recherche ordinaire, étendu par l’ajout à chaque
nœud x du champ taille[x] donnant le nombre de clés conservées dans le sous-arbre
enraciné en x. Soit a une constante de l’intervalle 1/2 a < 1. On dit qu’un nœud x
donné est a-équilibré si
taille[gauche[x]] a· taille[x]
et
taille[droit[x]] a· taille[x] .
L’arbre tout entier est dit a-équilibré si tout nœud de l’arbre est a-équilibré. L’approche amortie suivante, qui permet de gérer des arbres équilibrés pondérés, a été
suggérée par G. Varghese.
a. Un arbre 1/2-équilibré est, dans un sens, aussi équilibré que possible. Étant donné
un nœud x dans un arbre binaire de recherche quelconque, montrer comment reconstruire le sous-arbre enraciné en x pour qu’il devienne 1/2-équilibré. Votre
algorithme devra s’exécuter en Q(taille[x]) et pourra utiliser O(taille[x]) espace
de stockage auxiliaire.
b. Montrer qu’une recherche dans un arbre binaire de recherche a-équilibré à n
nœuds prend O(lg n) dans le cas le plus défavorable.
Problèmes
417
Pour le reste de ce problème, on suppose que la constante a est strictement supérieure
à 1/2. On admet que I NSÉRER et S UPPRIMER sont implémentés de la manière habituelle pour un arbre binaire de recherche de n nœuds hormis que, si après chacune
de ces opérations il y a un nœud de l’arbre qui n’est plus a-équilibré, le sous-arbre
ayant pour racine le plus haut des nœuds déséquilibrés est « reconstruit » pour devenir
1/2-équilibré.
On analysera ce schéma de reconstruction à l’aide de la méthode du potentiel. Pour
un nœud x d’un arbre binaire de recherche T, on définit
D(x) = |taille[gauche[x]] − taille[droite[x]]| ,
et le potentiel de T par
F(T) = c
D(x) ,
x∈T:D(x) 2
où c est une constante suffisamment grande qui dépend de a.
c Dunod – La photocopie non autorisée est un délit
c. Prouver qu’un arbre binaire de recherche quelconque possède un potentiel non
négatif et qu’un arbre 1/2-équilibré a pour potentiel 0.
d. On suppose que m unités de potentiel peuvent payer la reconstruction d’un sousarbre à m nœuds. Quelle doit être la valeur de c en fonction de a pour que la
reconstruction d’un sous-arbre qui n’est pas a-équilibré prenne un temps amorti
O(1) ?
e. Montrer que l’insertion ou la suppression d’un nœud dans un arbre a-équilibré à
n nœuds dépense un temps amorti O(lg n).
17.4. Coût de restructuration d’un arbre rouge-noir
Il existe quatre opérations fondamentales d’arbre rouge-noir qui effectuent des modifications structurelles : insertion de nœud, suppression de nœud, rotation et modification de couleur. On a vu que RN-I NSÉRER et RN-S UPPRIMER ne consomment
que O(1) rotations, insertions de nœud et suppressions de nœud pour préserver les
propriétés d’arbre rouge-noir, mais qu’elles risquent de faire beaucoup plus de modifications de couleur.
a. Décrire un arbre RN valide à n nœuds tel que l’appel de RN-I NSÉRER pour ajouter
le (n + 1)ème nœud provoque V(lg n) modifications de couleur. Décrire ensuite
un arbre RN valide à n nœuds tel que l’appel de RN-S UPPRIMER sur un nœud
particulier provoque V(lg n) modifications de couleur.
Le nombre de modifications de couleur par opération le plus défavorable qui soit
peut être logarithmique, mais nous allons montrer que toute suite de m opérations
RN-I NSÉRER et RN-S UPPRIMER sur un arbre RN initialement vide provoque O(m)
modifications structurelles dans le pire des cas.
17 • Analyse amortie
418
b. Certains des cas traités par la boucle principale des deux procédures RNI NSÉRER -C ORRECTION et RN-S UPPRIMER -C ORRECTION sont rédhibitoires :
ils forcent la boucle à se terminer après un nombre constant d’opérations supplémentaires. Pour chacun des cas de RN-I NSÉRER -C ORRECTION et RNS UPPRIMER -C ORRECTION, spécifier quels sont ceux qui sont rédhibitoires et
quels sont ceux qui ne le sont pas. (Conseil : Regarder les figures 13.5,13.6 et
13.7.)
On va analyser d’abord les modifications structurelles quand il n’y a que des insertions. Soient T un arbre RN et F(T) le nombre de nœuds rouges de T. On suppose
que 1 unité de potentiel peut payer les modifications structurelles effectuées par l’un
quelconque des trois cas de RN-I NSÉRER -C ORRECTION.
c. Soit T le résultat de l’application du cas 1 de RN-I NSÉRER -C ORRECTION à T.
Montrer que F(T ) = F(T) − 1.
d. L’insertion d’un nœud dans un arbre RN via RN-I NSÉRER peut se diviser en trois
parties. Énumérer les modifications structurelles et les changements de potentiel
qui résultent des lignes 1–16 de RN-I NSÉRER, des cas non rédhibitoires de RNI NSÉRER -C ORRECTION et des cas rédhibitoires de RN-I NSÉRER -C ORRECTION.
e. En utilisant la partie (d), montrer que le nombre amorti de modifications structurelles faites par un appel RN-I NSÉRER est O(1).
On veut maintenant prouver qu’il y a O(m) modifications structurelles quand on fait
des insertions et des suppressions. On définit, pour chaque nœud x,

0 si x est rouge ,



 1 si x est noir et n’a pas d’enfant rouge ,
w(x) =

0 si x est noir et a un seul enfant rouge ,



2 si x est noir et a deux enfants rouges .
On redéfinit alors le potentiel d’un arbre RN T par
w(x) ,
F(T) =
x∈T
Soit T l’arbre qui résulte de l’application d’un des cas non rédhibitoires de RNI NSÉRER -C ORRECTION ou RN-S UPPRIMER -C ORRECTION à T.
f. Montrer que F(T ) F(T) − 1 pour tous les cas non rédhibitoires de RNI NSÉRER -C ORRECTION. Prouver que le nombre amorti de modifications structurelles effectuées par un appel RN-I NSÉRER -C ORRECTION est O(1).
g. Montrer que F(T ) F(T) − 1 pour tous les cas non rédhibitoires de RNS UPPRIMER -C ORRECTION. Prouver que le nombre amorti de modifications structurelles effectuées par un appel RN-S UPPRIMER -C ORRECTION est O(1).
Notes
419
h. Compléter la démonstration que, dans le cas le plus défavorable, une séquence de
m opérations RN-I NSÉRER et RN-S UPPRIMER fait O(m) modifications structurelles.
NOTES
La méthode de l’agrégat pour l’analyse amortie fut utilisée par Aho, Hopcroft et Ullman
[5]. Tarjan [293] étudie la méthode comptable et celle du potentiel, et présente plusieurs
applications. Il attribue la méthode comptable à plusieurs auteurs, dont M. R. Brown, R. E.
Tarjan, S. Huddleston et K. Mehlhorn. Il attribue la méthode du potentiel à D. D. Sleator. Le
terme « amorti » est dû à D. D. Sleator et R. E. Tarjan.
c Dunod – La photocopie non autorisée est un délit
Les fonctions potentiel servent aussi à fournir des minorants pour certains types de problèmes. Pour chaque configuration du problème, on définit une fonction potentiel qui associe la configuration à un nombre réel. Ensuite, on détermine le potentiel Finit de la configuration initiale, le potentiel Ffinal de la configuration finale et la variation maximale de
potentiel DFmax due à une quelconque étape. Le nombre d’étapes doit donc être au moins
|Ffinal − Finit | / |DFmax |. On trouvera des exemples d’utilisation de fonctions potentiel pour
le calcul de minorants pour la complexité d’E/S dans les travaux de Cormen [71], Floyd [91]
et Aggarwal et Vitter [4]. Krumme, Cybenko et Venkataraman [194] se sont servi de fonctions potentiel pour établir des minorants pour le commérage, c’est-à-dire la diffusion d’un
élément unique entre tous les sommets d’un graphe.
PARTIE 5
c Dunod – La photocopie non autorisée est un délit
STRUCTURES DE DONNÉES
AVANCÉES
Cette partie traite des structures de données permettant de faire des opérations sur les
ensembles dynamiques, mais à un niveau plus avancé que dans la partie 3. Deux des
chapitres, par exemple, font un usage intensif des techniques d’analyse amortie vues
au chapitre 17.
Le chapitre 18 présente les B-arbres, qui sont des arbres de recherche équilibrés
spécialement conçus pour être stockés sur des disques magnétiques. Les disques étant
beaucoup plus lents que la mémoire RAM, les performances des B-arbres se mesurent
non seulement en fonction du temps de calcul requis par les opérations d’ensembles
dynamiques, mais aussi en fonction du nombre d’accès disque effectués. Pour chaque
opération de B-arbre, le nombre d’accès disque augmente avec la hauteur du B-arbre,
hauteur que les opérations de B-arbre maintiennent faible.
Les chapitres 19 et 20 donnent des implémentations de tas fusionnable qui reconnaissent les opérations I NSÉRER, M INIMUM, E XTRAIRE -M IN et U NION. (2) L’opération U NION réunit (fusionne) deux tas. Les structures de données de ces chapitres
reconnaissent également les opérations S UPPRIMER et D IMINUER -C LÉ.
Les tas binomiaux, traités au chapitre 19, donnent pour chacune de ces opérations
un temps O(lg n) dans le pire des cas, n étant le nombre total d’éléments du tas donné
(2) Au problème 10.2 on définit un tas fusionnable de façon qu’il reconnaisse M INIMUM et E XTRAIRE -M IN,
de sorte que l’on peut parler de tas min fusionnable. De même, si le tas reconnaît M AXIMUM et E XTRAIRE M AX, on parlera de tas max fusionnable. Sauf spécification contraire, nos tas fusionnables seront des tas
min.
422
Partie 5
en entrée (des deux tas donnés en entrée pour U NION). Pour l’opération U NION, les
tas binomiaux sont meilleurs que les tas binaires du chapitre 6, car la fusion de deux
tas binaires nécessite un temps Q(n) dans le cas le plus défavorable.
Les tas de Fibonacci, traités au chapitre 20, améliorent les tas binomiaux, du
moins en théorie. Leurs performances se mesurent à l’aide de bornes temporelles
amorties. Les opérations I NSÉRER, M INIMUM et U NION sur les tas de Fibonacci
ne prennent que O(1) de temps, réel ou amorti ; les opérations E XTRAIRE -M IN et
S UPPRIMER ont pour temps amorti O(lg n). Toutefois, l’avantage majeur des tas de
Fibonacci concerne D IMINUER -C LÉ dont le temps amorti est O(1) seulement. Le
faible temps amorti de D IMINUER -C LÉ explique que les tas de Fibonacci soient des
composants clé de certains des algorithmes les plus performants asymptotiquement
qui sont connus à ce jour en matière de problèmes de graphe.
Enfin, le chapitre 21 présente des structures de données pour les ensembles disjoints. On a un univers de n éléments, que l’on regroupe pour former des ensembles
dynamiques. Au départ, chaque élément appartient à son propre singleton. L’opération U NION fusionne deux ensembles et la requête T ROUVER -E NSEMBLE identifie
l’ensemble qui contient un élément donné à l’instant présent. En représentant chaque
ensemble par un arbre enraciné simple, on obtient des opérations étonnamment rapides : une séquence de m opérations s’exécute en un temps O(m a(n)), où a(n) est
une fonction à croissance très, très, très lente (a(n) vaut au plus 4 dans toutes les
applications imaginables). L’analyse amortie qui prouve cette borne temporelle est
aussi complexe que la structure de données est simple.
Les thèmes abordés dans cette partie ne sont pas les seuls exemples de structures
de données « avancées ». Voici quelques autres exemples de structures de données
avancées.
• Les arbres dynamiques, introduits par Sleator et Tarjan [281] et étudiés par Tarjan
[292], gèrent des forêts d’arbres disjoints. Chaque arc de chaque arbre possède un
coût à valeur réelle. Les arbres dynamiques reconnaissent les requêtes permettant
de trouver les parents, les racines, les coûts d’arc et le coût d’arc minimal sur un
chemin reliant un nœud à la racine. Les manipulations d’arbre vont de la coupure
de branche à la transformation d’un nœud en racine, en passant par l’actualisation
de tous les coûts d’arc sur un chemin reliant un nœud vers la racine et la connexion
d’une racine à un autre arbre. Une implémentation des arbres dynamiques donne
une borne temporelle amortie O(lg n) pour chaque opération ; une autre implémentation, plus compliquée, donne des bornes O(lg n) dans le pire des cas. Les arbres
dynamiques servent aussi dans certains des algorithmes de flot les plus rapides
asymptotiquement.
• Les arbres déployés, développés par Sleator et Tarjan [282] et étudiés par Tarjan [292], sont une forme d’arbre binaire de recherche sur laquelle les opérations
standard d’arbre de recherche s’exécutent avec un temps amorti O(lg n). L’une des
applications des arbres déployés simplifie les arbres dynamiques.
Structures de données avancées
423
• Les structures de données persistantes autorisent les recherches, et parfois aussi les
mises à jour, sur d’anciennes versions d’une structure de données. Driscoll, Sarnak,
Sleator et Tarjan [82] présentent des techniques permettant de rendre persistantes
des structures de données chaînées, avec un coût réduit en temps et en espace. Le
problème 13.1. donne un exemple simple d’ensemble dynamique persistant.
• Il existe plusieurs structures de données qui donnent une implémentation plus rapide des opérations de dictionnaire (I NSÉRER, S UPPRIMER et R ECHERCHER) sur
un univers de clés restreint. En exploitant ces restrictions, ces structures donnent
de meilleurs temps d’exécution asymptotiques, dans le cas le plus défavorable,
que les structures de données basées sur les comparaisons. Une structure inventée
par van Emde Boas [301] reconnaît les opérations M INIMUM, M AXIMUM, I NSÉ RER , S UPPRIMER , R ECHERCHER , E XTRAIRE -M IN , E XTRAIRE -M AX , P RÉDÉ CESSEUR et S UCCESSEUR avec un temps O(lg lg n) dans le cas le plus défavorable, à la condition que l’univers des clés soit restreint à {1, 2, . . . , n}. Fredman
et Willard ont introduit les arbres de fusion [99], qui ont été la première structure
de données à accélérer les opérations de dictionnaire quand l’univers se limite à
des nombres entiers. Ils ont montré comment implémenter ces opérations en un
temps O(lg n/ lg lg n). Plusieurs structures de données ont suivi, dont les arbres de
recherche exponentiels [16], qui améliorent encore les bornes pour tout ou partie
des opérations de dictionnaire ; ces structures sont mentionnées dans les notes de
fin de chapitre tout au long de cet ouvrage.
c Dunod – La photocopie non autorisée est un délit
• Les structures de données de graphe dynamique reconnaissent diverses requêtes
tout en permettant à la structure d’un graphe d’être modifiée par des opérations
qui insèrent ou suppriment des sommets ou des arcs. Exemples de requêtes reconnues : sommet-connexité [144] ; arc-connexité ; arbres couvrant minimaux [143] ;
biconnexité ; fermeture transitive [142].
Tout au long de ce livre, les notes de fin de chapitre mentionnent des structures de
données complémentaires.
Chapitre 18
B-arbres
c Dunod – La photocopie non autorisée est un délit
Les B-arbres sont des arbres de recherche équilibrés conçus pour être efficaces sur des
disques magnétiques ou autres unités de stockage secondaires à accès direct. Les Barbres ressemblent aux arbres rouge-noir (chapitre 13), mais ils sont plus performants
quand il s’agit de minimiser les entrées-sorties disque.
La différence majeure entre les B-arbres et les arbres rouge-noir réside dans le fait
que les nœuds des B-arbres peuvent avoir de nombreux enfant, jusqu’à plusieurs milliers. Autrement dit, le « facteur de ramification » d’un B-arbre peut être très grand,
bien qu’il soit généralement déterminé par les caractéristiques de l’unité de disque
utilisée. Les B-arbres ressemblent aux arbres rouge-noir au sens où tout B-arbre à n
nœuds a une hauteur O(lg n), bien que la hauteur d’un B-arbre puisse être très inférieure à celle d’un arbre rouge-noir car son facteur de ramification peut être beaucoup
plus grand. Cela permet donc également d’utiliser les B-arbres pour implémenter en
temps O(lg n) de nombreuses opérations d’ensemble dynamique.
Les B-arbres généralisent de façon naturelle les arbres binaires de recherche.
La figure 18.1 montre un B-arbre simple. Si un nœud interne x d’un B-arbre
contient n[x] clés, alors x possède n[x] + 1 enfants. Les clés du nœud x sont utilisées comme points de séparation de l’intervalle des clés gérées par x en n[x] + 1
sous-intervalles, chacun étant pris en charge par un enfant de x. Lorsqu’on recherche une clé dans un B-arbre, on prend une décision à (n[x] + 1) alternatives,
via des comparaisons avec les n[x] clés stockées dans le nœud x. La structure des
feuilles diffère de celle des nœuds internes ; nous étudierons ces différences à la
section 18.1.
18 • B-arbres
426
racine[T]
M
D H
B C
F G
Q T X
J K L
N P
R S
V W
Y Z
Figure 18.1 Un B-arbre dont les clés forment l’ensemble des consonnes. Un nœud interne x
contenant n[x] clés possède n[x] + 1 enfants. Toutes les feuilles ont la même profondeur dans
l’arbre. Les nœuds parcourus pendant la recherche de la lettre R sont en gris clair.
La section 18.1 donne une définition précise des B-arbres et démontre que la hauteur d’un B-arbre croît seulement de manière logarithmique avec le nombre de nœuds
qu’il contient. La section 18.2 décrit comment rechercher ou insérer une clé dans un
B-arbre, et la section 18.3 traite de la suppression. Mais avant de continuer, il convient
de se demander pourquoi les structures de données adaptées aux disques magnétiques
demandent une évaluation différente de celles qui sont conçues pour fonctionner en
mémoire principale.
a) Structures de données sur supports de stockage secondaires
Il existe de nombreuses techniques pour fournir de la mémoire à un système informatique. La mémoire centrale (ou mémoire principale) d’un ordinateur est constituée
habituellement de puces de silicone. Le coût de cette technologie (en termes de coût
du bit stocké) est de l’ordre de cent fois le coût des stockages magnétiques comme les
bandes ou les disques. Un système possède en général de la mémoire secondaire sous
forme de disques magnétiques ; le volume de stockage de ces supports secondaires
est souvent cent à mille fois plus important que la capacité de la mémoire principale.
La figure 18.2 montre un lecteur de disque classique. Le disque se compose de
plusieurs plateaux, qui tournent à vitesse constante autour d’un axe central commun.
La surface de chaque plateau est recouverte de particules magnétisables. Chaque plateau est lu ou écrit par une tête située à l’extrémité d’un bras. Les bras, qui sont reliés
entre eux physiquement, peuvent rapprocher ou éloigner leurs têtes de l’axe central.
Quand une tête est stationnaire, la surface qui défile au-dessous d’elle est une piste.
Les têtes de lecture-écriture sont alignées verticalement en permanence, et donc les
pistes qui défilent sous elles sont accédées simultanément. La figure 18.2(b) montre
un ensemble de pistes, connu sous le nom de cylindre.
Les disques sont meilleur marché et ont plus de capacité que la mémoire centrale,
mais ils sont beaucoup, beaucoup plus lents car ils renferment des composants mécaniques mobiles. Il y a deux types de mouvements mécaniques dans un disque :
la rotation des plateaux et le déplacement des bras. À l’heure où nous écrivons ces
lignes, les disques usuels tournent à des vitesses de 5 400 à 15 000 tours par minute,
B-arbres
plateau
427
axe de rotation
piste
tête de lecture/écriture
pistes
bras
(a)
(b)
Figure 18.2 (a) Lecteur de disque typique. Il se compose de plusieurs plateaux tournant autour
d’un axe central. La lecture et l’écriture de chaque plateau sont faites par une tête située à l’extrémité d’un bras. Les bras sont construits de façon telle que leurs têtes se déplacent à l’unisson.
Ici, les bras tournent autour d’un axe pivot commun. Une piste est la surface qui défile sous la
tête de lecture-écriture quand celle-ci est stationnaire. (b) Un cylindre se compose d’un ensemble
de pistes coaxiales.
c Dunod – La photocopie non autorisée est un délit
7 200 tr/mn étant la valeur la plus courante. 7 200 tr/mn peut sembler rapide ; mais un
tour prend 8,33 ms, ce qui est presque cent mille fois plus lent que les temps d’accès de 100 nanosecondes qui est très courant pour les puces de silicone. En d’autres
termes, si l’on doit attendre un tour complet pour qu’un certain élément vienne sous
la tête de lecture-écriture, pendant ce délai on pourra accéder à la mémoire centrale
100 000 fois ! En moyenne on n’attend que le temps d’une demie révolution, mais la
différence de temps d’accès entre puce de silicone et disque reste néanmoins énorme.
Le déplacement des bras demande aussi du temps. À l’heure où nous écrivons ces
lignes, le temps d’accès moyen pour un disque usuel est de l’ordre de 3 à 9 ms.
Pour amortir le temps passé à attendre les déplacements mécaniques, un disque
accède à plusieurs éléments simultanément. Les données sont réparties en un certain
nombre de pages de bits ayant la même taille et rangées de manière contiguë dans
les cylindres ; chaque lecture ou écriture sur le disque implique une ou plusieurs
pages toutes entières. Pour un disque typique, une page fait entre 211 et 214 octets de
longueur. Une fois que la tête de lecture-écriture est positionnée correctement et que
le disque a tourné pour que le début de la page désirée soit sous la tête, la lecture ou
l’écriture est entièrement électronique (mis à part la rotation du disque), et il est alors
possible de transférer rapidement de gros volumes de données.
Souvent, il est plus long pour l’ordinateur d’accéder à une page et de la lire sur un
disque que de traiter ensuite toutes les données lues. C’est pour cette raison que, dans
18 • B-arbres
428
ce chapitre, nous étudierons séparément les deux composantes principales du temps
d’exécution :
– le nombre d’accès au disque et
– le temps CPU (temps de calcul).
Le nombre d’accès au disque se mesure en fonction du nombre de pages de données
qui devront être lues ou écrites sur le disque. On remarque que le temps d’accès au
disque n’est pas constant : il dépend de la distance entre la piste courante et la piste
à atteindre, et aussi de l’état de rotation initial du disque. Nous allons néanmoins
utiliser le nombre de pages lues ou écrites comme première approximation du temps
total consommé par les accès au disque.
Dans une application de B-arbre classique, la quantité de données gérées est si
grande qu’elles ne tiennent pas toutes en même temps dans la mémoire principale.
Les algorithmes de B-arbre copient quand il le faut certaines pages du disque vers
la mémoire principale et réécrivent sur le disque les pages modifiées. Comme ces
algorithmes n’ont besoin que d’un nombre constant de pages en mémoire principale
à un instant donné, la taille des B-arbres sur lesquels ils travaillent n’est pas limitée
par celle de la mémoire principale.
Les opérations d’accès au disque sont modélisées dans notre pseudo code de la
manière suivante. Soit x un pointeur vers un objet. Si l’objet se trouve dans la mémoire principale de l’ordinateur, on peut faire référence aux champs de l’objet de la
façon habituelle : clé[x], par exemple. En revanche, si l’objet pointé par x se trouve
sur le disque, on doit effectuer l’opération L IRE -D ISQUE(x) pour amener l’objet x en
mémoire principale avant de pouvoir référencer ses champs. (On suppose que, si x
est déjà en mémoire principale, L IRE -D ISQUE(x) n’exige pas d’accès disque ; c’est
une non-opération ou « nop ».) De la même façon, l’opération É CRIRE -D ISQUE(x)
sert à sauvegarder toutes les modifications intervenues dans les champs de l’objet x.
Autrement dit, le modèle général de travail sur un objet est le suivant :
1
2
3
4
5
x ← un pointeur vers un certain objet
L IRE -D ISQUE(x)
opérations qui accèdent aux champs de x et/ou les modifient
Omis si aucun champ de x n’a été modifié.
É CRIRE -D ISQUE(x)
autres opérations, qui accèdent aux champs de x sans les modifier
À un instant donné, le système ne peut garder qu’un nombre de pages limité en mémoire principale. On suppose que les pages qui ne sont plus utilisées sont vidées
de la mémoire principale par le système ; nos algorithmes de B-arbre ignoreront ce
problème.
Puisque dans la plupart des systèmes, le temps d’exécution d’un algorithme de
B-arbre est surtout déterminé par le nombre d’opérations L IRE -D ISQUE et É CRIRE D ISQUE qu’il effectue, il est logique d’utiliser au mieux ces opérations pour qu’elles
lisent ou écrivent le plus d’informations possible à chaque accès. Un nœud de B-arbre
18.1
Définition d’un B-arbre
429
aura donc le plus souvent la taille d’une page de disque entière. Le nombre d’enfants
que pourra posséder un nœud est donc limité par la taille d’une page sur le disque.
Pour un grand B-arbre stocké sur un disque, on utilise fréquemment des facteurs de
ramification compris entre 50 et 2000, qui dépendent du rapport entre la taille d’une
clé et la taille d’une page. Un grand facteur de ramification réduit énormément à la
fois la hauteur de l’arbre et le nombre d’accès disque nécessaires pour trouver une
clé. La figure 18.3 montre un B-arbre ayant un facteur de ramification 1001 et une
hauteur 2, capable de stocker plus d’un milliard de clés ; pourtant, comme le nœud
situé à la racine peut être gardé en permanence dans la mémoire principale, il suffit
de deux accès au disque pour accéder à une clé quelconque de cet arbre !
racine[T]
1 nœud,
1000 clés
1000
1001
1000
1000
1001
1001
1000
1000
…
1000
1001 nœuds,
1,001,000 clés
1001
…
1000
1,002,001 nœuds,
1,002,001,000 clés
Figure 18.3 Un B-arbre de hauteur 2 contenant plus d’un milliard de clés. Chaque nœud (feuilles
comprises) contient 1 000 clés. Il existe 1 001 nœuds à la profondeur 1 et plus d’un millions de
feuilles à la profondeur 2. On peut voir à l’intérieur de chaque nœud la valeur n[x], qui représente
le nombre de clés de x.
c Dunod – La photocopie non autorisée est un délit
18.1 DÉFINITION D’UN B-ARBRE
Pour simplifier on supposera, comme nous l’avons fait pour les arbres de recherche
binaires et les arbres rouge-noir, que toute « donnée satellite » associée à une clé
est stockée dans le même nœud que la clé. En pratique, il est possible de ne stocker
avec chaque clé qu’un pointeur vers une autre page du disque, qui contiendra les
informations satellites associées à cette clé. Le pseudo code de ce chapitre suppose
implicitement que les informations satellites (ou le pointeur qui les adresse) voyagent
avec la clé à chaque fois que la clé passe d’un nœud à l’autre. Une autre organisation
fréquemment utilisée pour les B-arbres consiste à stocker toutes les informations satellites dans les feuilles et à ne conserver dans les nœuds internes que les clés et les
pointeurs d’enfant, ce qui permet de maximiser le facteur de ramification des nœuds
internes.
Un B-arbre T est une arborescence (de racine racine[T]) possédant les propriétés
suivantes.
1) Chaque nœud x contient les champs ci-dessous :
a) n[x], le nombre de clés conservées actuellement par le nœud x,
18 • B-arbres
430
b) les n[x] clés elles-mêmes, stockées par ordre non décroissant :
clé1 [x] clé2 [x] · · · clén[x] [x],
c) feuille[x], une valeur booléenne qui vaut VRAI si x est une feuille et FAUX si x
est un nœud interne.
2) Chaque nœud interne x contient également n[x] + 1 pointeurs c1 [x], c2 [x],
. . . , cn[x]+1 [x] vers ses enfant. Les feuilles n’ont pas d’enfants, et leurs champs ci
ne sont donc pas définis.
3) Les clés cléi [x] déterminent les intervalles de clés stockés dans chaque sous-arbre :
si ki est une clé stockée dans le sous-arbre de racine ci [x], alors
k1 clé1 [x] k2 clé2 [x] · · · clén[x] [x] kn[x]+1 .
4) Toutes les feuilles ont la même profondeur, qui est la hauteur h de l’arbre.
5) Il existe un majorant et un minorant pour le nombre de clés pouvant être contenues
par un nœud. Ces bornes peuvent être exprimées en fonction d’un entier fixé t 2,
appelé le degré minimal du B-arbre :
a) Tout nœud autre que la racine doit contenir au moins t − 1 clés. Tout nœud
interne autre que la racine possède donc au moins t enfant. Si l’arbre n’est pas
vide, la racine doit posséder au moins une clé.
b) Tout nœud peut contenir au plus 2t − 1 clés. Un nœud interne peut donc posséder au plus 2t enfants. On dit qu’un nœud est complet s’il contient exactement
2t − 1 clés. (1)
Le B-arbre le plus simple qui puisse être est celui pour lequel t = 2. Tout nœud
interne possède alors 2, 3 ou 4 enfants et on se trouve en présence d’un arbre 2-3-4.
Toutefois, en pratique, on utilise des valeurs de t beaucoup plus grandes.
b) Hauteur d’un B-arbre
Le nombre d’accès disque nécessaires pour la plupart des opérations sur un B-arbre
est proportionnel à la hauteur du B-arbre. Analysons à présent la hauteur d’un B-arbre
dans le cas le plus défavorable.
Théorème 18.1 Si n 1, alors, pour tout B-arbre T à n clés de hauteur h et de degré
minimal t 2,
h logt
n+1
.
2
Démonstration : Si un B-arbre a une hauteur h, la racine contient au moins une clé
et tous les autres nœuds contiennent au moins t − 1 clés. Par conséquent, il y a au
moins 2 nœuds à la profondeur 1, au moins 2t nœuds à la profondeur 2, au moins 2t2
(1) Une autre variante courante de B-arbre, appelée B£ -arbre, exige que chaque nœud interne soit au moins
rempli aux 2/3, et non pas rempli au moins à moitié comme l’exige un B-arbre.
18.2
Définition d’un B-arbre
431
nœuds à la profondeur 3, etc. jusqu’à la profondeur h il y a au moins 2th−1 nœuds. La
figure 18.4 montre un tel arbre pour h = 3. Ainsi, le nombre n de clés obéit à l’inégalité
n 1 + (t − 1)
h
2ti−1 = 1 + 2(t − 1)
i=1
th − 1
t−1
= 2th − 1 .
Avec un peu d’algèbre élémentaire, l’on obtient th (n + 1)/2. En prenant les logarithmes de base t des deux membres, on démontre le théorème.
❑
racine[T]
profondeur
1
t–1
t–1
t
t
…
t–1
t
t–1
…
t–1
t–1
t–1
t–1
t
t
…
t–1
t–1
…
…
t–1
nombre
de nœuds
0
1
1
2
2
2t
3
2t2
t
t–1
t–1
…
t–1
Figure 18.4 B-arbre de hauteur 3 contenant un nombre de clés minimal. À l’intérieur de chaque
nœud x est affiché n[x].
On voit ici la puissance des B-arbres comparés aux arbres rouge-noir. Bien que la
hauteur de l’arbre augmente en O(lg n) dans les deux cas (n’oubliez pas que t est une
constante), dans le cas des B-arbres la base du logarithme peut être très supérieure.
Du coup, les B-arbres économisent un facteur d’environ lg t par rapport aux arbres
rouge-noir, au niveau du nombre de nœuds examinés par la plupart des opérations
d’arbre. Comme l’examen d’un nœud demande habituellement un accès disque, le
nombre d’accès au disque est sensiblement réduit.
c Dunod – La photocopie non autorisée est un délit
Exercices
18.1.1 Pourquoi n’autorise-t-on pas t = 1 comme degré minimal ?
18.1.2 Pour quelles valeurs de t l’arbre de la figure 18.1 est-il un B-arbre valide ?
18.1.3 Donner tous les B-arbres valides de degré minimal 2 permettant de représenter
{1, 2, 3, 4, 5}.
18.1.4 En tant que fonction du degré minimal t, quel est le nombre maximal de clés qui
peuvent être stockées dans un B-arbre de hauteur h ?
18.1.5 Décrire la structure de données que l’on obtiendrait si chaque nœud noir d’un arbre
rouge-noir devait absorber ses enfants rouges, en incorporant leurs enfants aux siens.
432
18 • B-arbres
18.2 OPÉRATIONS FONDAMENTALES SUR LES B-ARBRES
Dans cette section, nous présentons en détail les opérations R ECHERCHER -BA RBRE, C RÉER -B-A RBRE et I NSÉRER -B-A RBRE. Pour ces procédures, nous
adopterons deux conventions :
– La racine du B-arbre se trouve toujours en mémoire principale, de manière qu’une
opération L IRE -D ISQUE ne soit jamais nécessaire pour la racine ; cependant, un
appel à É CRIRE -D ISQUE sera nécessaire à chaque modification du nœud racine.
– Tout nœud passé en paramètre devra avoir subi auparavant une opération L IRE D ISQUE.
Les procédures présentées ici implémentent toutes des algorithmes « une passe » qui
partent de la racine pour descendre l’arbre mais qui ne reviennent jamais en arrière.
a) Recherche dans un B-arbre
Les recherches dans un B-arbre ressemblent beaucoup à celles effectuées dans un
arbre binaire de recherche, excepté qu’au lieu de prendre à chaque nœud une décision de branchement binaire (deux options au choix), on prend une décision à options
multiples, selon le nombre d’enfants du nœud. Plus précisément, à chaque nœud interne x, on prend une décision parmi (n[x] + 1) options possibles.
R ECHERCHER -B-A RBRE est une généralisation directe de la procédure
R ECHERCHER -A RBRE définie pour les arbres binaires de recherche. R ECHERCHER B-A RBRE prend en entrée un pointeur vers la racine x d’un sous-arbre et une
clé k à rechercher dans ce sous-arbre. L’appel de niveau supérieur est donc de
la forme R ECHERCHER -B-A RBRE(racine[T], k). Si k se trouve dans le B-arbre,
R ECHERCHER -B-A RBRE retourne le couple (y, i) constitué d’un nœud y et d’un
indice i tel que cléi [y] = k. Sinon, la valeur NIL est retournée.
R ECHERCHER -B-A RBRE(x, k)
1 i←1
2 tant que i n[x] et k > cléi [x]
3
faire i ← i + 1
4 si i n[x] et k = cléi [x]
5
alors retourner (x, i)
6 si feuille[x]
7
alors retourner NIL
8
sinon L IRE -D ISQUE(ci [x])
9
retourner R ECHERCHER -B-A RBRE(ci [x], k)
En employant une méthode de recherche linéaire, les lignes 1–3 trouvent le plus petit
indice i pour lequel k cléi [x] ou, s’il n’est pas trouvé, donnent à i la valeur n[x] + 1.
Les lignes 4–5 testent si la clé a été découverte, auquel cas elle est retournée. Les
lignes 6–9 permettent soit d’arrêter la recherche si elle échoue (si x est une feuille),
18.2
Opérations fondamentales sur les B-arbres
433
soit d’effectuer une recherche récursive dans le sous-arbre de x approprié, après avoir
effectué sur ce enfant le L IRE -D ISQUE obligatoire.
La figure 18.1 illustre l’action de R ECHERCHER -B-A RBRE ; les nœuds en gris
clair sont ceux examinés pendant la recherche de la clé R.
Comme pour la procédure R ECHERCHER -A RBRE des arbres binaires de recherche, les nœuds rencontrés pendant la récursivité forment un chemin qui descend
de la racine de l’arbre. Le nombre de pages disque auxquelles R ECHERCHER -BA RBRE accède est donc O(h) = O(logt n), où h est la hauteur du B-arbre et n le
nombre de clés qu’il contient. Comme n[x] < 2t, le temps pris par la boucle tant que
des lignes 2–3 à l’intérieur de chaque nœud est O(t) et le temps CPU total est
O(th) = O(t logt n).
b) Création d’un B-arbre vide
Pour construire un B-arbre T, on commence par appeler C RÉER -B-A RBRE pour
créer un nœud racine vide ; ensuite, on appelle I NSÉRER -B-A RBRE pour ajouter de
nouvelles clés. Ces deux procédures utilisent une procédure auxiliaire A LLOUER N ŒUD, qui alloue en O(1) une page disque pour le nouveau nœud. On peut supposer
qu’un nœud créé par A LLOUER -N ŒUD ne nécessite aucun appel à L IRE -D ISQUE,
puisqu’il n’existe pour l’instant aucune information utile sur le disque concernant ce
nœud.
C RÉER -B-A RBRE(T)
1 x ← A LLOUER -N ŒUD()
2 feuille[x] ← VRAI
3 n[x] ← 0
4 É CRIRE -D ISQUE(x)
5 racine[T] ← x
C RÉER -B-A RBRE requiert O(1) opérations de disque, pour un temps CPU O(1).
c Dunod – La photocopie non autorisée est un délit
c) Insertion d’une clé dans un B-arbre
L’insertion d’une clé dans un B-arbre est nettement plus complexe que l’insertion
d’une clé dans un arbre binaire de recherche. Comme c’est le cas avec un arbre binaire
de recherche, on cherche la position de feuille à laquelle il faut insérer la nouvelle clé.
Mais avec un B-arbre, on ne peut pas se contenter de créer une nouvelle feuille puis
de l’insérer, car l’arbre résultant ne serait pas un B-arbre licite. À la place, on insère
la nouvelle clé dans un nœud feuille existant. Comme on ne peut pas insérer une clé
dans un nœud feuille qui est plein, on introduit une opération qui partage un nœud y
plein (ayant 2t − 1 clés) autour de sa clé médiane clét [y], pour en faire deux nœuds
ayant chacun t − 1 clés. La clé médiane remonte dans le parent de y pour identifier le
point de partage entre les deux nouveaux arbres. Mais si le parent de y est plein, lui
aussi, il faut le partager avant d’y insérer la nouvelle clé ; par conséquent, il se peut
que ce processus de partage de nœuds pleins se propage dans tout l’arbre (en partant
du bas pour aller vers le haut).
18 • B-arbres
434
Comme c’est le cas avec un arbre binaire de recherche, on peut insérer une clé dans
un B-arbre en une seule passe qui descend l’arbre depuis la racine pour arriver sur
une feuille. Pour ce faire, on n’attend pas de savoir s’il faudra partager un nœud plein
pour faire l’insertion. À la place, à mesure que l’on descend l’arbre pour chercher la
position de la nouvelle clé, on partage chaque nœud plein que l’on rencontre sur le
chemin (y compris la feuille elle-même). Ainsi, quand on voudra partager un nœud
plein y, on sera assuré que son parent n’est pas plein.
➤ Partage de nœud dans un B-arbre
La procédure PARTAGER -E NFANT-B-A RBRE prend en entrée un nœud interne non
plein x (supposé être en mémoire centrale), un indice i et un nœud y (censé, lui aussi,
être en mémoire centrale), tels que y = ci [x] soit un enfant plein de x. La procédure
partage alors cet enfant en deux et modifie x pour qu’il ait un enfant de plus. (Pour
partager une racine pleine, on commence par faire de la racine un enfant d’un nouveau
nœud racine vide de façon à pouvoir utiliser PARTAGER -E NFANT-B-A RBRE). La
hauteur de l’arbre augmente donc de un ; le partage est la seule façon de faire croître
l’arbre.
La figure 18.5 illustre ce processus. Le nœud plein y est partagé au niveau de sa clé
médiane S, qui remonte vers le parent de y, à savoir le nœud x. Les clés de y qui sont
supérieures à la clé médiane vont dans un nouveau nœud z, qui devient un nouvel
enfant de x.
]
]
[x x] 1[x
[
+
i
i
é
cl cl clé
x
… N S W …
]
[x x]
é [i
cl cl
x
… N W …
1
1
é i–
é i–
y = ci[x]
y = ci[x]
z = ci+1[x]
P Q R S T U V
P Q R
T U V
T1 T2 T3 T4 T5 T6 T7 T8
T1 T2 T3 T4
T5 T6 T7 T8
Figure 18.5 Partage d’un nœud avec t = 4. Le nœud y est partagé en deux nœuds, y et z, et la
clé médiane S de y va dans le parent de y.
PARTAGER -E NFANT-B-A RBRE(x, i, y)
1 z ← A LLOUER -N ŒUD()
2 feuille[z] ← feuille[y]
3 n[z] ← t − 1
4 pour j ← 1 à t − 1
5
faire cléj [z] ← cléj+t [y]
6 si non feuille[y]
7
alors pour j ← 1 à t
8
faire cj [z] ← cj+t [y]
18.2
9
10
11
12
13
14
15
16
17
18
19
Opérations fondamentales sur les B-arbres
435
n[y] ← t − 1
pour j ← n[x] + 1 jusqu’à i + 1
faire cj+1 [x] ← cj [x]
ci+1 [x] ← z
pour j ← n[x] jusqu’à i
faire cléj+1 [x] ← cléj [x]
cléi [x] ← clét [y]
n[x] ← n[x] + 1
É CRIRE -D ISQUE(y)
É CRIRE -D ISQUE(z)
É CRIRE -D ISQUE(x)
PARTAGER -E NFANT-B-A RBRE fonctionne en faisant du « couper-coller » direct.
Ici, y est le ième enfant de x et c’est le nœud qui est partagé. Le nœud y a, à l’origine,
2t enfants (2t − 1 clés), mais il est réduit à t enfants (t − 1 clés) par cette opération.
Le nœud z « adopte » les t plus grands enfants (t − 1 clés) de y, et z devient un nouvel
enfant de x, placé juste après y dans la table des enfants de x. La clé médiane de y
remonte pour devenir la clé de x qui sépare y et z.
Les lignes 1–8 créent le nœud z, puis lui donnent les t − 1 plus grandes clés et
les t enfants correspondants de y. La ligne 9 modifie le compteur de clés de y. Enfin, les lignes 10–16 insèrent z en tant qu’enfant de x, font remonter la clé médiane
de y à x afin de séparer y de z, puis ajustent le compteur de clés de x. Les lignes
17–19 réécrivent sur disque toutes les pages modifiées. Le temps CPU consommé
par PARTAGER -E NFANT-B-A RBRE est Q(t), en raison des boucles des lignes 4–5
et 7–8. (Les autres boucles font O(t) itérations.) La procédure effectue O(1) opérations sur le disque.
➤ Insertion d’une clé dans un B-arbre en une seule passe descendante
c Dunod – La photocopie non autorisée est un délit
On insère une clé k dans un B-arbre T de hauteur h en une seule passe qui descend
l’arbre, au prix de O(h) accès disque. Le temps CPU requis est O(th) = O(t logt n).
La procédure I NSÉRER -B-A RBRE emploie PARTAGER -E NFANT-B-A RBRE pour assurer que la récursivité ne descendra jamais sur un nœud plein.
I NSÉRER -B-A RBRE(T, k)
1 r ← racine[T]
2 si n[r] = 2t − 1
3
alors s ← A LLOUER -N ŒUD()
4
racine[T] ← s
5
feuille[s] ← FAUX
6
n[s] ← 0
7
c1 [s] ← r
8
PARTAGER -E NFANT-B-A RBRE(s, 1, r)
9
I NSÉRER -B-A RBRE -I NCOMPLET(s, k)
10
sinon I NSÉRER -B-A RBRE -I NCOMPLET(r, k)
18 • B-arbres
436
Les lignes 3–9 gèrent le cas où le nœud racine r est plein : la racine est alors partagée,
et un nouveau nœud s (ayant deux enfants) devient la racine. Partager la racine est
l’unique façon d’augmenter la hauteur d’un B-arbre. La figure 18.6 illustre ce cas.
Contrairement à un arbre binaire de recherche, un B-arbre croît par le haut et non
par le bas. La procédure s’achève par l’appel de I NSÉRER -B-A RBRE -I NCOMPLET
pour faire l’insertion de la clé k dans l’arbre enraciné sur le nœud racine non plein.
I NSÉRER -B-A RBRE -I NCOMPLET fait autant d’appels récursifs que nécessaire au
cours de sa descente de l’arbre, garantissant à tout instant que le nœud sur lequel
elle fait de la récursivité n’est pas plein, et ce en appelant PARTAGER -E NFANT-BA RBRE en fonction des besoins.
racine[T]
s
H
racine [T]
r
A D F H L N P
r
A D F
L N P
T1 T2 T3 T4 T5 T6 T7 T8
T1 T2 T3 T4
T5 T6 T7 T8
Figure 18.6 Partage de la racine avec t = 4. Le nœud racine r est partagé en deux, et il y a
création d’un nouveau nœud racine s. La nouvelle racine contient la clé médiane de r et a comme
enfants les deux moitiés de r. Le B-arbre croit d’une unité quand il y a partage de la racine.
La procédure auxiliaire récursive I NSÉRER -B-A RBRE -I NCOMPLET insère la clés
k dans le nœud x, qui est censé être non plein lors de l’appel de la procédure. Le
fonctionnement de I NSÉRER -B-A RBRE et le fonctionnement récursif de I NSÉRER B-A RBRE -I NCOMPLET assurent que cette hypothèse est vraie.
I NSÉRER -B-A RBRE -I NCOMPLET(x, k)
1 i ← n[x]
2 si feuille[x]
3
alors tant que i 1 et k < cléi [x]
4
faire cléi+1 [x] ← cléi [x]
5
i←i−1
6
cléi+1 [x] ← k
7
n[x] ← n[x] + 1
8
É CRIRE -D ISQUE(x)
9
sinon tant que i 1 et k < cléi [x]
10
faire i ← i − 1
11
i←i+1
12
L IRE -D ISQUE(ci [x])
13
si n[ci [x]] = 2t − 1
14
alors PARTAGER -E NFANT-B-A RBRE(x, i, ci [x])
15
si k > cléi [x]
16
alors i ← i + 1
17
I NSÉRER -B-A RBRE -I NCOMPLET(ci [x], k)
18.2
Opérations fondamentales sur les B-arbres
437
La procédure I NSÉRER -B-A RBRE -I NCOMPLET fonctionne ainsi. Les lignes 3–8
gèrent le cas où x est une feuille, en insérant la clé k dans x. Si x n’est pas une feuille,
alors on doit insérer k dans le nœud feuille idoine du sous-arbre enraciné sur le nœud
interne x. Dans ce cas, les lignes 9–11 déterminent l’enfant de x sur lequel doit descendre la récursivité. La ligne 13 détecte si la récursivité descendrait sur un enfant
plein, auquel cas la ligne 14 utilise PARTAGER -E NFANT-B-A RBRE pour diviser cet
enfant en deux enfants non pleins, et les lignes 15–16 déterminent lequel des deux
enfants est maintenant l’enfant sur lequel il faut descendre. (Notez que point n’est
besoin d’un appel L IRE -D ISQUE(ci [x]) après que la ligne 16 a incrémenté i, car la
récursivité descendra dans ce cas sur un enfant qui vient d’être créé par PARTAGER E NFANT-B-A RBRE.) L’effet net des lignes 13–16 est donc de garantir que la procédure ne va jamais faire de récursivité sur un nœud plein. La ligne 17 fait ensuite de la
récursivité pour insérer k dans le sous-arbre idoine. La figure 18.7 illustre les divers
cas d’insertion dans un B-arbre.
Le nombre d’accès disque effectué par I NSÉRER -B-A RBRE est O(h) pour un
B-arbre de hauteur h, car il n’y a que O(1) opérations L IRE -D ISQUE et É CRIRE D ISQUE qui sont faites entre les appels à I NSÉRER -B-A RBRE -I NCOMPLET. Le
temps CPU total consommé est de O(th) = O(t logt n). Comme I NSÉRER -B-A RBRE I NCOMPLET est récursive par la queue, on peut aussi la mettre en œuvre sous la
forme d’une boucle tant que, démontrant que le nombre de pages qui doivent résider
en mémoire centrale à un instant quelconque est O(1).
Exercices
18.2.1 Montrer les résultats de l’insertion successive des clés
F, S, Q, K, C, L, H, T, V, W, M, R, N, P, A, B, X, Y, D, Z, E
c Dunod – La photocopie non autorisée est un délit
dans un B-arbre vide de degré minimal 2. Ne représenter que les configurations de l’arbre qui
précèdent immédiatement le découpage d’un nœud, puis représenter la configuration finale.
18.2.2 Décrire dans quelles circonstances éventuelles l’exécution d’un appel I NSÉRER -BA RBRE pourrait engendrer des opérations L IRE -D ISQUE ou É CRIRE -D ISQUE redondantes.
(Un L IRE -D ISQUE redondant concerne une page qui se trouve déjà en mémoire. Un É CRIRE D ISQUE redondant écrit sur le disque une page identique à celle déjà présente sur le disque.)
18.2.3 Dire comment trouver la clé minimale stockée dans un B-arbre et comment trouver
le prédécesseur d’une clé donnée d’un B-arbre.
18.2.4 On suppose que les clés {1, 2, . . . , n} sont insérées dans un B-arbre vide de degré
minimal 2. Combien de nœuds possédera le B-arbre final ?
18.2.5 Comme les feuilles n’ont pas besoin de pointeurs d’enfant, on pourrait concevoir
qu’elles utilisent une valeur de t différente (plus grande) que les nœuds internes pour la même
taille de page disque. Montrer comment modifier les procédures de création et d’insertion
dans un B-arbre pour gérer cette variante.
18 • B-arbres
438
(a) arbre initial
A C D E
G M P X
J K
N O
(b) B inséré
R S T U V
Y Z
G M P X
A B C D E
J K
(c) Q inséré
N O
R S T U V
G M P T X
A B C D E
J K
N O
Q R S
(d) L inséré
U V
Y Z
P
G M
A B C D E
T X
J K L
(e) F inséré
N O
Q R S
U V
Y Z
P
C G M
A B
Y Z
D E F
J K L
T X
N O
Q R S
U V
Y Z
Figure 18.7 Insertion de clés dans un B-arbre. Le degré minimal t de ce B-arbre est 3, de sorte
qu’un nœud peut contenir au plus 5 clés. Les nœuds en gris clair sont ceux qui seront modifiés
par le processus d’insertion. (a) L’arbre initial de cet exemple. (b) Le résultat de l’insertion de B
dans l’arbre initial ; c’est une insertion simple dans une feuille. (c) Le résultat de l’insertion de Q
dans l’arbre précédent. Le nœud RSTUV est divisé en deux nœuds contenant RS et UV, la clé T
remonte dans la racine et Q est inséré dans la moitié de gauche (nœud RS). (d) Le résultat de
l’insertion de L dans l’arbre précédent. La racine est divisée immédiatement car elle est pleine, et
le B-arbre croît en hauteur de un. Ensuite, L est inséré dans la feuille contenant JK. (e) Le résultat
de l’insertion de F dans l’arbre précédent. Le nœud ABCDE est divisé, puis F est inséré dans la
moitié de droite (nœud DE).
18.2.6 On suppose que R ECHERCHER -B-A RBRE est implémentée de manière à utiliser une
recherche dichotomique, plutôt qu’une recherche linéaire, à l’intérieur de chaque nœud. Montrer que le temps CPU requis devient alors O(lg n), indépendamment de la façon dont t est
choisi comme fonction de n.
18.3
Suppression d’une clé dans un B-arbre
439
18.2.7 On suppose que le disque est construit de façon qu’on puisse choisir arbitrairement
la taille d’une page disque, mais que le temps pris pour lire la page soit a + bt, où a et b sont
des constantes spécifiées et t le degré minimal d’un B-arbre utilisant des pages de cette taille.
Décrire la manière de choisir t pour minimiser (approximativement) le temps de recherche
dans le B-arbre. Suggérer une valeur optimale de t pour le cas où a = 5 ms et b = 10
microsecondes.
18.3 SUPPRESSION D’UNE CLÉ DANS UN B-ARBRE
c Dunod – La photocopie non autorisée est un délit
La suppression dans un B-arbre ressemble à l’insertion, en un peu plus compliqué.
En effet, la suppression d’une clé peut concerner un nœud quelconque et pas seulement une feuille ; en outre, la suppression dans un nœud interne exige que les enfants
du nœud soient réorgansiés. Comme avec l’insertion, il faut éviter qu’une suppresion
ne produise un arbre dont la structure enfreint les propriétés de B-arbre. De même
que nous devions faire en sorte qu’un nœud ne grossisse pas trop pour cause d’insertion, nous devons faire en sorte qu’un nœud ne rétrécisse pas trop pour cause de
suppression (sachant que la racine est autorisée à avoir moins de clés que le minimum
t − 1, mais qu’elle n’a pas le droit d’en avoir plus que le maximum 2t − 1). Un algorithme d’insertion simpliste risquerait d’être obligé de rebrousser chemin si le nœud
où il faudrait insérer la clé est plein ; de même, une approche simpliste en matière de
suppression nous obligerait à revenir en arrière si le nœud (autre que la racine) dans
lequel il faudrait supprimer la clé a le nombre minimal de clés.
Supposons que la procédure S UPPRIMER -B-A RBRE doive supprimer la clé k du
sous-arbre enraciné en x. Cette procédure est faite de telle façon que, chaque fois que
S UPPRIMER -B-A RBRE est appelée récursivement sur un nœud x, le nombre de clés
de x est au moins égal au degré minimal t. Notez que cette condition exige une clé
de plus que le minimum requis par les conditions de B-arbre ; il peut donc advenir
qu’une clé soit obligée d’aller dans un nœud enfant avant que la récursivité descende
sur cet enfant. Cette condition renforcée permet de supprimer une clé de l’arbre en
une seule passe descendante, sans risque de « retour en arrière » (à une seule exception, que nous allons expliquer). À propos des spécifications suivantes concernant la
suppression dans un B-arbre, il faut bien comprendre ceci : s’il arrive que le nœud racine x devienne un nœud interne n’ayant pas de clés (cette situation peut se produire
dans les cas 2c et 3b, donnés ci-après), alors x est supprimé et l’unique enfant de x,
à savoir c1 [x], devient la nouvelle racine de l’arbre (dont la hauteur diminue de un)
et préserve la propriété énonçant que la racine de l’arbre contient au moins une clé
(sauf si l’arbre est vide).
Nous allons esquisser le fonctionnement de la suppression, au lieu de présenter le
pseudo code. La figure 18.8 illustre les divers cas de la suppression de clé dans un
B-arbre.
18 • B-arbres
440
(a) arbre initial
P
C G M
A B
D E F
T X
J K L
N O
(b) F supprimé : cas 1
Q R S
D E
T X
J K L
N O
(c) M supprimé : cas 2a
Q R S
D E
J K
N O
Q R S
U V
Y Z
P
C L
D E J K
Y Z
T X
(d) G supprimé : cas 2c
A B
U V
P
C G L
A B
Y Z
P
C G M
A B
U V
T X
N O
Q R S
U V
Y Z
Figure 18.8 Suppression de clés dans un B-arbre. Le degré minimal de ce B-arbre est t = 3, de
sorte qu’un nœud (autre que la racine) ne peut pas avoir moins de 2 clés. Les nœuds qui sont
modifiés sont en gris clair. (a) Le B-arbre de la figure 18.7(e). (b) Suppression de F. C’est le cas 1 :
suppression simple dans une feuille. (c) Suppression de M. C’est le cas 2a : le prédécesseur L de M
remonte pour prendre la place de M. (d) Suppression de G. C’est le cas 2c : G est poussé vers le
bas pour créer le nœud DEGJK, puis G est supprimé de cette feuille (cas 1). (e) Suppression de D.
C’est le cas 3b : la récursivité ne peut pas descendre sur le nœud CL, car il n’a que 2 clés ; donc,
P est poussé vers le bas et fusionné avec CL et TX pour former CLPTX ; ensuite, D est supprimé
d’une feuille (cas 1). (e¼ ) Après (d), la racine est supprimée et la hauteur de l’arbre diminue de un.
(f) Suppression de B. C’est le cas 3a : C est déplacé pour occuper la place de B et E est déplacé
pour occuper la place de C.
18.3
Suppression d’une clé dans un B-arbre
441
(e) D supprimé : cas 3b
C L P T X
A B
E J K
(e′) la hauteur de l'arbre
diminue
A B
E J K
(f) B supprimé : cas 3a
A C
J K
N O
Q R S
U V
Y Z
U V
Y Z
C L P T X
N O
Q R S
E L P T X
N O
Q R S
U V
Y Z
1) Si la clé k est dans le nœud x et que x est une feuille, on supprime la clé k de x.
2) Si la clé k est dans le nœud x et que x est un nœud interne, on procède comme suit.
a) Si l’enfant y qui précède k dans le nœud x a au moins t clés, on cherche le
prédécesseur k de k dans le sous-arbre enraciné en y. On supprime récursivement k , puis on remplace k par k dans x. (Trouver k et le supprimer sont deux
actions qui peuvent se faire dans une même passe descendante.)
b) De manière symétrique, si l’enfant z qui suit k dans le nœud x a au moins t clés,
on cherche le successeur k de k dans le sous-arbre enraciné en z. On supprime
récursivement k , puis on remplace k par k dans x. (Trouver k et le supprimer
sont deux actions qui peuvent se faire dans une même passe descendante.)
c Dunod – La photocopie non autorisée est un délit
c) Sinon, si y et z n’ont que t − 1 clés chacun, on fusionne k avec le contenu
de z et on fait passer le tout dans y ; ce faisant, x perd k plus le pointeur vers
z et y contient désormais 2t − 1 clés. Ensuite, on libère z et l’on supprime
récursivement k de y.
3) Si la clé k ne figure pas dans le nœud interne x, on cherche la racine ci [x] du sousarbre contenant k (pour autant que k soit dans l’arbre). Si ci [x] n’a que t − 1 clés,
on exécute l’étape 3a ou 3b (selon les besoins) pour garantir que l’on va descendre
sur un nœud qui contient bien au moins t clés. Ensuite, on termine en appliquant
la récursivité à l’enfant approprié de x.
a) Si ci [x] n’a que t − 1 clés mais qu’il a un frère immédiat ayant au moins t clés,
on donne à ci [x] une clé supplémentaire de la façon que voici : on prend une
clé de x que l’on fait descendre dans ci [x], on prend une clé du frère immédiat
(de gauche ou de droite) de ci [x] que l’on fait monter dans x, et on déplace le
pointeur d’enfant approprié pour le faire passer du frère à ci [x].
18 • B-arbres
442
b) Si les frères immédiats de ci [x] et de ci [x]’s ont chacun t − 1 clés, on fusionne
ci [x] avec un frère ; cela implique qu’il faut faire descendre une clé de x dans le
nœud nouvellement fusionné pour qu’elle devienne la clé médiane de ce nœud.
Comme la plupart des clés d’un B-arbre sont dans les feuilles, on peut s’attendre
à ce que, dans la pratique, les suppressions de clé se fassent le plus souvent sur des
feuilles. La procédure S UPPRIMER -B-A RBRE travaille alors en une seule passe descendante, sans risque de devoir revenir en arrière dans l’arbre. En revanche, quand
on supprime une clé d’un nœud interne, la procédure descend l’arbre mais elle peut
éventuellement être amenée à revenir sur le nœud dont on a supprimé la clé, pour
remplacer la clé par son prédécesseur ou successeur (cas 2a et 2b).
Cette procédure peut paraître compliquée, mais elle ne demande que O(h) accès
disque pour un B-arbre de hauteur h ; en effet, il n’y a que O(1) appels L IRE -D ISQUE
et É CRIRE -D ISQUE entre les invocations récursives de la procédure. Le temps CPU
requis est O(th) = O(t logt n).
Exercices
18.3.1 Montrer le résultat de la suppression dans l’ordre de C, P et V dans l’arbre de la
figure 18.8(f).
18.3.2 Écrire le pseudo code de S UPPRIMER -B-A RBRE.
PROBLÈMES
18.1. Piles sur un espace de stockage secondaire
On considère l’implémentation d’une pile dans un ordinateur assez limité en mémoire
principale et bien pourvu en espace disque. Les opérations E MPILER et D ÉPILER sont
supportées pour des valeurs contenues dans un mot. La pile devra pouvoir croître
jusqu’à dépasser largement la mémoire principale disponible, ce qui impose d’en
stocker la plus grande partie sur disque.
Conserver la pile entièrement sur le disque est simple quoique peu efficace. On
garde en mémoire un pointeur vers la pile, qui représente l’adresse sur disque du
sommet de la pile. Si le pointeur a une valeur p, l’élément du sommet est le (p mod
m)ème mot sur la page p/m du disque, où m est le nombre de mots par page.
Pour implémenter l’opération E MPILER, on incrémente le pointeur de pile, on
charge en mémoire 
Related documents
Download