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