ISBN: 9782340—010055

Текст
                    Christooh Dürr
Jill-Jênn Vie
Programmation
efficace
Les 128 algorithmes qu'il faut avoir compris
et codés en Python au cours de sa vie


Références sciences  Programmation efficace  Les 128 algorithmes qu'il faut avoir compris et codés en Python au cours de sa vie  Christoph Dürr Jill-Jënn Vie  Préparation aux concours de programmation 
Collection Références sciences  dirigée par Paul de Laboulaye  paul.delaboulaye@editions-ellipses.fr  Retrouvez tous les livres de la collection et des extraits sur www.editions-ellipses.fr  CAñe  r  —s  ISBN 9782340—010055 DANGER ©Ellipses Édition Marketing S.A., 2016 PHOTOCHELLAGE 32, rue Bargue 75740 Paris cedex 15 TUELE UVRE  Le Code de la propriété intellectuelle n’autorisant, aux termes de l’article L. 122-5.2° et 3°a), d’une part, que les « copies ou reproductions strictement réservées à l’usage privé du copiste et non destinées à une utilisation collective », et d’autre part, que les analyses et les courtes citations dans un but d’exemple et d’illustration, « toute représentation ou reproduction intégrale ou partielle faite sans le consentement de l’auteur ou de ses ayants droit ou ayants cause est illicite » (art. L. 122-4). Cette représentation ou reproduction, par quelque procédé que ce soit constituerait une contrefaçon sanctionnée par les articles L. 335-2 et suivants du Code de la propriété intellectuelle.  www.editions-ellipses.fr 
Avant-propos  Ce livre est le résultat d’une passion pour la résolution de problèmes algorith- miques et pour la programmation en Python. Ce qui rend ce langage si attrayant, c’est qu'il permet une écriture particulièrement épurée et claire, faisant ressortir les étapes essentielles de l’algorithme sans les occulter derrière des notations lourdes pour ma- nipuler les structures de données. Ainsi, de façon édifiante, il est encore possible de relire son code Python plusieurs mois plus tard. Les auteurs de ce livre aiment avant tout découvrir de nouveaux challenges, et dans un second temps passer tous les tests, car un algorithme n’est maîtrisé qu’une fois qu’il a été implémenté sans bug. Conta- miner le lecteur avec cette passion a un pouvoir pédagogique immense, car celui-ci va développer une culture solide en algorithmique, en rigueur et en programmation. Cette expérience sera très appréciée lors d’entretiens d’embauche de grandes entre- prises en informatique, et même pendant toute la carrière d’un ingénieur ou d’un professeur d’informatique. Nous avons ici répertorié 128 problèmes algorithmiques par thème, plutôt que par technique. Certains sont classiques, d’autres atypiques. Cet ouvrage se révélera utile pour résoudre une grande variété de problèmes que l’on rencontre dans les concours de programmation tels que ACM-ICPC, Google Code Jam, Facebook Hacker Cup, Prologin, France-ioi. Nous espérons qu'il servira de base à un cours d'algorithmique et de programmation avancée, ou même que certains candidats à l’agrégation de ma- thématiques option informatique y trouveront quelques développements originaux. Le lecteur pourra également trouver sur le site tryalgo.org une bibliothèque des codes qui composent ce livre, ainsi qu’une sélection de liens vers des problèmes pour tester la correction de ses algorithmes, et la performance de leur implémentation. Ce livre n'aurait pas vu le jour sans le soutien quotidien des partenaires dans la vie des auteurs. Danke Huong. Merci @. Les auteurs remercient également les élèves de l’École polytechnique et de l’École normale supérieure de Cachan, dont l'entraînement parfois nocturne a produit une bonne partie du matériel de ce livre. Enfin, merci à tous ceux qui ont pu relire le manuscrit, à savoir principalement René Adad, Evripidis Bampis, Binh-Minh Bui-Xuan, Stéphane Henriot, L Thanh Düng Nguyêën, Alexandre Nolin et Antoine Pietri. L’un des auteurs tient tout particuliè- rement à remercier son ancien professeur du lycée Thiers, Monsieur Yves Lemaire, pour lui avoir fait découvrir la pépite à admirer section 2.5 page 42. Nous souhaitons au lecteur de passer de longues heures devant des problèmes algorithmiques au premier abord insurmontables, et de finalement ressentir la joie profonde de trouver soudainement une solution, voire une solution élégante.  Attention, c’est parti. 
Table des matières  1 Introduction 7 1.1 Les concours de programmation ..... 2211111111 1 e 7 1.1.1 Sites d'entraînement .. .....22 200044111142 220 9 1.12 Réponses des juges .. ...2 222114411444 4 4 14e e e 10 1.2 Notre choix:Python ......22 21104211 L L 4 4 1 C 1 e e 11 13 Entrées-sorties. ... ...2424 424142042141 111 1211120 12 1.31 Lirel'entréestandard......22 1221441441 aL 12 1.3.2 < Pormat.de l'afléhage © 021 1004 1remetinerarere baareisiaanaiate « 15 14 Complexité.. ... 122101444444 4 4 4 4 4 4 4 4 4 4 4 2 15 1.5 Types abstraits et structures de données essentielles .. ......... 17 L5 1 Pilé.< u7 35 iv e RRs PL T SEE OS - 17 1.52 Dictionnaire .. ....22 2041414144 L 4 L 4 L 4 4 18 153 Flé . rervaviseraaus ce s0 E cd » 18 1.5:4 - Filédepricritéettas. .. .. . 12 VS T TIS NTUIES e … 19 155 Union-find > 5220 Te e e CT S DU TUKDIEN . 23 LL6 — Techniques 72417 C1Ts 0 e à e à - PS TOT SLOI 24 LG1 Camparer 27e PMS P A es 24 162 Trier....452 005054s se 021000 MREDI … : 25 L6S - Balayage .. . 2 212 » 2 à , un TT mineomee … À 25 1.64 Algorithmes gloutons ......122 21041111 LL 14 26 1.65 Programmation dynamique .. ....2 224122111142 27 1.6.6 Coder des ensembles dans des entiers .. .........22 40 28 1.6.7 Recherche dichotomique. .. ....22 221141111142 29 1./. Conseils ....2201010 01 T ESSSEES 31 18 Pouraller plusloin .. .1222 0001242211 L L L L L L L 2 33 2 Chaînes de caractères 35 2.1 Anagrammes ... 22244444 L L L L 4L L L 4 L 1L 1L L L 1L 35 22 T9—textesur9touches ... .. 2212000124 L 4 L 4 L 4 L L 1L 0 36 2.3 Correcteur orthographique ......220 11102 L 1220 38 24 Recherche de motifs — Knuth-Morris-Pratt .. .. ....422422 200 40 2.5 Bords maximaux — Knuth-Morris-Pratt. .. ......44 22200440 41 26 - Chaîne en puissance 2151F 44 2.7 Recherche de motifs - Rabin-Karp .......222 22121112 45 2.8 Plus long palindrome d’une chaîne -Manacher.............. 48 
TABLE DES MATIÈRES  Séquences 3.1 Plus court chemin dans une grille ... ... 444444 4411144 44 3.2 Distance d'édition de Levenshtein ......44442 2444444406 3.3 Plus longue sous-séquence commune ......14044 4585505 e « 34 Plus longue sous-séquence croissante ......44 8885414480 3.5 Stratégie gagnante dans un jeu à deux joueurs .. .....2212 1- «  Tableaux 41 Fusion de listes triées .. .. ... 2242444444 24es A e es 4.2 Somme d’une plage .. ... ..44 24444444 44e e e e e e e 43 Doublon d’une plage ... ... 1121444444 e 144453 A e e es 44 Plus grande somme d’une plage ......14 4444 e e e es « 45 Requêtes de minimum d'une plage - arbre de segments .. ... ... 46 Requêtes de somme d’une plage - arbre de Fenwick ... ... 1- 4.7 Fenêtres avec k éléments distincts . . . . ... 444411454444 0 6  Intervalles 5.1 Arbre d’intervalles .. .. ... 22444444 44 4 4 16440 40 e « 5.2 Union d’intervalles .. .. ... 220044444224 414 4 4 e 100 5.3 Couverture d’intervalles .. ... ...42 4244444 4 4 44442406  Graphes 6.1 CodageenPython.......420 4422 44e 4 A e e e e e e e e e 62 CodageenC++ouJava ......220 444444 e e e e e e e 6.3 Graphes implicites .......4 4444444 e e e e e e es es 6.4 Parcours en profondeur-DFS. .. ....22 444414115144 6.5 Parcours en largeur—-BFS ..... 22244221 e e e e s« 6.6 Composantes connexes ... ....22 21044444 e se e e e e e 6.7 Composantes bi-connexes ........4 4444144 e es e e se 68 Tritopologique .....2202 0444 44e e e e e e e e e e 6.9 Composantes fortement connexes . .. ... 114448454511 4 800 6.10 2-satisfiabilité .. .....2002 00004 44 46 444 0 084 6 0 00e 06  Cycles dans les graphes 71 Chemineulérien. .......0 044444444414 4 4 e e e e e e 7.2 Problème du postier chinois .. . ... 214441444444 e 44e 7.3 Cycles de ratio poids sur longueur minimal-Karp. ... ...+..-- 74 Cycles de ratio coût sur temps minimal ... ... 41411114 75 Voyageurdecommerce.......24 44444 a se 45e e e e e e «  Plus courts chemins 8.1 Propriété decomposition. ... ... 22044444 41e se es e e 82 Graphes avec poids0oul .. ... 24241141 ee e e e e e e « 8.3 Graphes avec poids positifs ou nuls - Dijkstra ........114 000 84 Graphes avec poids arbitraires — Bellman-Ford ......14440 0 e « 85 Toutes paires source-destination — Floyd-Warshall .. .....22 4- 86 Grille .. ...200 . 2tace e le 00 00 c00e 1 e e e e e T0 0  51 51 52 54 55 58  59 59 60 60 61 61 
TABLE DES MATIÈRES  9  10  11  12  13  8.7 Variantes .. .. . 2 24 46 4 0000 ce 000 00 0 0 E E 0ù tn caé r h D é 871 Graphenonpondéré ......12220 0044111144 4 8.72 Graphe orienté acyclique .......222 2221144222 0 8.73 Pluslongchemin ....222 0012444 a e e e 8.74 Pluslong chemin dans unarbre .......211 1422110 8.75 Chemin qui minimise le poids maximal sur les arcs .. .. . .. 8.76 Graphe pondéré sur lessommets .. .......22 244400 8.7.7 Chemin qui minimise le poids maximal sur les sommets .. . . 8.7.8 Toutes les arêtes appartenant à un plus court chemin .. .. ..  Couplages et flots 9.1 Couplage maximum biparti .. ...22 0001024444 4 22e 9.2 Couplage parfait de poids maximal - Kuhn-Munkres .......... 9.3 Couplage planaire sans croisement .. .....222 2241111220 9.4 Mariages stables — Gale-Shapley .......2202 2211112226 9.5 Flot maximum par Ford-Fulkerson .. ......2224 41122220 9.6 Flot maximum par Edmonds-Karp .. .....22 1224414 a 9.7 . Flot mMaximum par Dimic . .… . . « 4T 10 A AOBHAQiB L 2 98 s-tcoupeminimum .....222 2104411144 L L L L L 9.9 s-— t coupe minimum pour graphe planaire .. .....222224200 9.10 Problème de transport .. .. .22 00411411 L L L L L 11110 9.11 Réduction entre couplages et flots. .. .....1212 2004111440 9.12 Largeur d’un ordre partiel - Dilworth .......22 22111222 0  Arbres 10.1 Code de Huffman .. ... 211100 L L 4 L L L 111 L 20 10.2 Ancêtre commun le plus proche .. ....2 2020444 4 44e e 10.3 Plus long chemin dans unarbre .. ....2 22111111 110 10.4 Arbre couvrant de poids minimal - Kruskal..........244000  Ensembles 111 Saéädos .1 5010 SE R TS 11.2 Rendu de monnaie .....22 224111111111 L 1110 11.3 Sous-ensemble de valeur totale donnée .......22 2220410 114 ksomme .. ....2 2144 4 L L L L L L L L L L L 111110  Points et polygones 12.1 Enveloppe convexe .....220 200104 V e 660600480100 12.2 Mesures d’un polygone ......20240 044211144111 4L 12.3 Paire de points les plus proches .. ......2 24444444s 12.4 Polygone rectilinéaire simple ......2222 1021122120  Rectangles 13.1 Former des rectangles .....22 2041111 1L 11110 13.2 Plus grand carré dans une grille .. 1.22 22020444 11144 e 13.3 Plus grand rectangle dans un histogramme ..........20242000 13.4 Plus grand rectangle dans une grille .. ....22224 2022002200 
6 TABLE DES MATIÈRES  13.5 Union de rectangles .. ... ... 244410424444 4 e e e se e 173 13.6 Union de rectangles disjoints .. .....20224 44411222 177 14 Calculs 179 141 PGCD ... 0000040000 cOathis es cn R ln d mfiobeu Th -AULR »6 179 14.2 Coefficients.de-Bézout-iveures 5ie e ristare TS t OD GU S e e 179 143 Coefficients binomiaux .. .. ... 220444411144 44e e 180 144 Exponentiationrapide ... ....+..0245000 002N 113400 180 145 Nombres/premiers;:. ;; [us su À irmamotiéaice n celdan e sl agients A 1B . 181 14.6 Évaluer une expression arithmétique .. ... ..22 2221414120 181 14.7 Systèmes d'équations linéaires ........2424242 2111121226 184 14.8 Multiplication d'une séquence de matrices ...........42000 188 15 Exploration exhaustive 191 15.1 Tous les chemins pour un laser .. .....2 4244444144 e 22e 191 15.2 Couverture exacte .. ......22 442204424441 4 4144 14446 194 15.3-Sudoku ... 0202000 pa ue USs acle ec (c tA0y careiarer KT . à 200 15.4 Énumération de permutations. .. ........2200 21204044 0 201 15.5 Lecompteestbon ............220 0004000 0000500000 204 Bibliographie 209  Index 211 
1. Introduction  Toi le jeune, va apprendre à coder les algorithmes de ce livre, puis gagne des concours de programmation, fais-toi accepter dans des entretiens d’embauche, et augmente le produit intérieur brut avec tes manches retroussées.  À tort, les informaticiens sont encore souvent considérés comme les magiciens des temps modernes. Les ordinateurs se sont petit à petit introduits dans les entreprises, les maisons, les machines, devenant des moteurs importants du fonctionnement de notre monde. Et de nombreuses personnes utilisent ces machines sans les maîtriser, sans qu'’elles puissent entièrement répondre à leurs besoins. Savoir programmer per- met alors d’exploiter au mieux leurs possibilités pour résoudre des problèmes de manière efficace. Algorithmique et programmation sont devenus un bagage incon- tournable dans bien des métiers. Maîtriser ces techniques permet alors de proposer des solutions informatiques créatives et efficaces aux problèmes de tous les jours. Cet ouvrage expose différentes techniques algorithmiques pour des problèmes classiques, décrit des situations de la vie où ces problèmes surgissent, et montre des implémentations simples écrites dans le langage Python. Implémenter correctement un algorithme n’est pas toujours facile, il y a bien des pièges à éviter et des bonnes techniques à appliquer pour garantir la complexité en temps annoncée. Les implé- mentations de ce livre sont agrémentées d’explications sur les détails importants à respecter. Depuis des dizaines d'années, des concours de programmation sont organisés dans le monde entier à tous les niveaux, afin de promouvoir une culture algorith- mique large. Les problèmes proposés dans ces concours sont souvent des variantes de problèmes algorithmiques classiques, sous la forme d’énigmes agaçantes à résoudre qui ne vous lâcheront plus.  1.1 Les concours de programmation  Dans un concours de programmation, les candidats doivent résoudre plusieurs problèmes dans un temps imparti. Un problème est souvent une variante d’un pro- blème classique, comme ceux abordés dans ce livre, et enrobé d’une petite histoire. Les entrées des problèmes sont appelés instances. Une instance peut par exemple 
8 CHAPITRE 1. INTRODUCTION  être la matrice d’adjacence d’un graphe pour un problème de plus court chemin. En général, un petit exemple d’instance avec sa solution est donné. Le code source d’une solution peut être transmis au serveur par une interface web, où il est compilé puis testé contre des instances cachées au public. Pour certains problèmes, le code est appelé pour chaque instance, pour d’autres l’entrée débute par un entier indiquant le nombre d’instances qui figureront dans l’entrée. Le programme devra alors dans une boucle lire chaque instance, la résoudre et afficher le résultat. La soumission est acceptée si elle donne les bonnes réponses dans un temps limite de l’ordre de la seconde.  Ficure 1.1 — Le logo du concours ACM montre joli- ment les étapes de la résolution d’un problème. Un ballon gonflé à l’hélium est distribué aux équipes pour chaque problème résolu.  Donner ici une liste de tous les concours de programmation et sites d’entraînement est simplement impossible et serait vite dépassé. Néanmoins, nous allons passer en revue les plus importants.  ACM/ICPC Le plus ancien des concours est organisé par l’association ACM (As- sociation for Computing Machinery) depuis 1977. Ce concours, portant le nom de ICPC pour International Collegiate Programming Contest, est organisé sous la forme d’un tournoi. Le point de départ pour les Français est le concours ré- gional SWERC (South-West European Regional Contest) où les deux meilleures équipes se qualifient pour la finale mondiale. La particularité de ce concours est que chaque équipe de 3 personnes n’a qu’un seul ordinateur à sa disposition. Elles ont 5 heures pour résoudre un nombre maximum de problèmes parmi les 10 proposés. Le premier critère de classement est le nombre de solutions soumises et acceptées (testées contre un jeu de tests inconnu). Le deuxième, la somme pour chaque problème du temps écoulé entre le début du concours et le moment de la soumission acceptée. Pour chaque soumission erronée, une pénalité de 20 minutes est ajoutée. Il y a plusieurs théories sur la composition d’une bonne équipe. En général il faut au moins un bon codeur et un bon algorithmicien, et des spécialistes dans différents domaines tels les graphes, la programmation dynamique, etc. Et il faut bien s’entendre, même en situation de stress. Lors du concours, on peut apporter 25 pages de code imprimées en taille 8 points pour référence. On dispose également de la documentation en ligne de l’API de Java et de la bibliothèque standard de C++.  Google Code Jam Contrairement au concours ACM qui est limité aux étudiants jus- qu’au master, le Google Code Jam est ouvert à tous. Ce concours annuel est plus récent et la participation est individuelle. Chaque problème vient en général avec un jeu de petites instances dont la résolution donne déjà quelques points et un jeu de grandes instances où il est vraiment important d'avoir trouvé 
à $  r e é e  mtn p  1.1. LES CONCOURS DE PROGRAMMATION 9  un algorithme avec une bonne complexité. On est informé de l’acceptation de sa solution pour les grandes instances seulement à la fin du concours. Mais son véritable point fort est qu’il est possible après coup de consulter les solu- tions soumises par tous les participants, ce qui est extrêmement instructif. Le concours Facebook Hacker Cup est de nature similaire.  Prologin L'association française Prologin organise chaque année un concours destiné aux étudiants de vingt ans et moins. Leur capacité à résoudre des problèmes d'algorithmique y est mise à l’épreuve au cours de trois étapes : une sélection en ligne, des épreuves régionales, et la finale. Cette dernière est une rencontre atypique de trente-six heures, au cours desquelles les participants doivent faire face à un sujet d’intelligence artificielle. Chaque candidat doit programmer un champion qui joue à un jeu dont les règles sont définies par les organisateurs, et à l’issue de l’épreuve, un tournoi confronte leurs champions et détermine le palmarès. Leur site web prologin.org comporte des annales très complètes, où l’on peut tester ses algorithmes.  France-ioi L'association France-ioi prépare chaque année des collégiens ou lycéens aux olympiades internationales d’informatique. Depuis 2012, ils organisent le concours Castor informatique, qui s'adresse aux jeunes de la 6° à la terminale (228 000 participants en 2014). Leur site web france-ioi .org héberge une quantité phénoménale de problèmes d’algorithmique (plus de 1 000).  Il existe également de nombreux concours de programmation organisés dans le but de sélectionner des candidats pour des offres d’emploi. Le site TopCoder par exemple comporte aussi des tutoriels algorithmiques, qui sont souvent bien écrits. Pour l’entraînement nous recommandons particulièrement Codeforces, un site très respecté dans la communauté de la programmation en compétition, car il propose des problèmes soignés et clairs.  1.1.1 Sites d’entraînement  Plusieurs sites web proposent des problèmes issus d’annales de concours, avec la possibilité de tester ses solutions pour s’entraîner. C’est notamment le cas de Google Code Jam et Prologin. Les annales des concours ACM sont collectées à différents endroits.  Juges en ligne traditionnels Les sites uva.onlinejudge.org, icpcarchive.ecs.- baylor.edu et livearchive.onlinejudge .org contiennent entre autres beau- coup de problèmes issus du concours ACM/ICPC.  Juges en ligne chinois Il existe maintenant de nombreux sites d’entraînement en Chine comme poj.org, acm.tju.edu.cnet acm.zju.edu.cn. Ils ont une inter- face plus épurée que les juges traditionnels. Cependant, des pannes sporadiques ont été observées.  Sphere Online Judge Le site spoj.com a l'avantage d’accepter la soumission de solutions en beaucoup de langages dont Python.  Sur le site tryalgo.org, qui accompagne cet ouvrage, vous trouverez une sélec- tion de problèmes qui reposent sur le contenu des différentes sections. Vous aurez 
10 CHAPITRE 1. INTRODUCTION  ainsi l’occasion de mettre en pratique les algorithmes décrits dans ce livre, en testant votre implémentation sur un juge en ligne. Les langages utilisés pour les concours de programmation sont principalement C++ et Java. Le concours Google Code Jam accepte tous les langages — car la résolution est à faire localement — et le juge SPOJ accepte aussi le Python, comme mentionné ci-haut. Pour faire face aux différences entre les vitesses d’exécution dues au choix du langage, les juges en ligne varient en général le temps limite en fonction du langage utilisé. Mais cette adaptation n’est pas toujours faite avec soin, et il est souvent difficile de faire passer une solution correctement écrite en Python. Nous espérons que cette situation changera dans les années à venir. Certains juges aussi travaillent avec une ancienne version de Java, pour laquelle des classes pratiques comme Scanner par exemple ne sont pas disponibles.  1.12 Réponses des juges  Lorsque vous soumettez un code à un juge en ligne pour un problème, celui- ci l’évalue via un jeu de tests privé et vous renvoie une réponse particulièrement succincte. Les principaux codes de retour sont les suivants.  Accepted Votre programme a donné les bonnes réponses dans le temps imparti. Félicitations.  Presentation Error C’est presque un programme accepté, mais vous affichez des espaces ou retours de ligne en trop ou en moins. Ce message est rare.  Compilation Error La compilation de votre programme a produit des erreurs. Sou- vent en cliquant sur ce message, Vous aurez la nature de cette erreur. Comparez la version du compilateur utilisé par le juge avec celle du vôtre.  Wrong Answer Relisez l’énoncé, un détail a dû vous échapper. Êtes-vous sûr d’avoir testé tous les cas limites ? Auriez-vous laissé des affichages de débogage dans votre code ?  Time Limit Exceeded Vous n’avez probablement pas implémenté l’algorithme le plus efficace pour ce problème, ou bien vous avez une boucle infinie quelque part. Vérifiez les invariants de boucle pour vous assurer de la terminaison. Générez une grande instance et testez en local la performance de votre code.  Runtime Error En général, il peut s'agir d’une division par zéro, d'un dépassement de limite de tableau, ou bien d’un pop() sur une pile vide. Mais d'autres situa- tions peuvent générer ce message, comme l’utilisation de assert en Java, qui n’est souvent pas acceptée.  Le comportement taciture des juges permet néanmoins d’extraire certaines in- formations sur les instances. Voici une astuce qui a été utilisée lors du concours ACM/ICPC/SWERC. Dans un problème concernant les graphes, l’énoncé précisait que l'entrée était constituée de graphes connexes. Une équipe a eu un doute et a écrit un test de connexité. Dans le cas positif le programme entrait dans une boucle infinie et dans le cas négatif, il faisait une division par zéro. Le code d'erreur généré par le juge (Time Limit Exceeded ou Runtime Error) a permis à l’équipe de détecter que certains graphes de l'entrée n'étaient pas connexes. 
1.2. NOTRE CHOIX : PYTHON . 11  1.2 Notre choix : Python  Le langage de programmation Python a été choisi pour ce livre, pour sa lisibilité et simplicité d'utilisation. En industrie, il est parfois utilisé pour produire des prototypes de programmes. Python est également le langage retenu pour des projets importants comme SAGE par exemple dont les parties critiques sont alors écrites dans des langages plus rapides comme C++ ou C. Donnons quelques précisions sur ce langage. En Python, il existe quatre types de base : les booléens, les entiers, les nombres à décimale flottante et les chaînes de caractères. Contrairement à la plupart des autres langages de programmation, les entiers ne sont pas limités à un nombre de bits fixé, mais utilisent une représentation à précision arbitraire. Les principales structures avancées sont les dictionnaires, les listes et les tuplets. La différence entre les derniers est que les tuplets sont des données immuables et peuvent être utilisés comme clés dans un dictionnaire. Des bonnes introductions à Python sont disponibles en ligne, par exemple sur python.org. David Eppstein a mis à disposition des implémentations en Python très instructives dans une bibliothèque nommée Python Algorithms and Data Structures (PADS). Pour la rédaction du code de ce livre, nous avons suivi la norme PEPS, qui fournit des recommandations précises sur l’usage des blancs, du choix des noms de variables, etc. Nous suggérons au lecteur de suivre également ces indications.  Python 20u 3? La version 3 de Python est sortie en 2008, mais encore aujourd’hui de nombreux développements se font en Python 2, car de nombreuses bibliothèques n’ont pas été portées en Python 3. Malgré ce fait, nous avons choisi d’implémenter les algorithmes en Python 3. Les principaux changements qui affectent les codes proposés dans ce livre portent sur print et la division entre entiers. En Python 3, pour deux entiers a et b, l'expression a / b renvoie le nombre flottant qui résulte de la division tandis que a // b en renvoie le quotient euclidien (la partie entière). Et contrairement à Python 2, où print est un mot clé, en Python 3 c’est une fonction, qu'’il faut donc appeler avec des paramètres entre parenthèses. Si votre programme a des problèmes de performance il peut être intéressant de l'exécuter avec l'interpréteur pypy ou pypy3, qui est un compilateur à la volée. Ceci veut dire que votre code Python est d’abord traduit dans un code machine, qui ensuite est exécuté nettement plus rapidement. L'inconvénient est que pypy est encore en cours de développement et que certaines parties de la bibliothèque Python ne sont pas encore accessibles.  Infini Python travaille en précision arbitraire et ne limite pas les entiers à un nombre de bits fixé, Donc il n’existe pas d’équivalent entier pour représenter —c et +co. Pour les nombres flottants en revanche, on peut utiliser float(“-inf”) et float(+inf”).  Quelques conseils Une erreur souvent commise par des débutants en Python concerne la copie de tableaux. Dans l'exemple suivant, le tableau B est juste une référence vers A. Une modification de B[0] modifie A[0] également. 
12 CHAPITRE 1. INTRODUCTION  A = [1,2,31 B A  Pour que B soit une copie distincte de À, on peut utiliser la syntaxe suivante.  A B  [1,2,3] A[:]  La notation [:] permet de faire une copie d’une liste. On peut également faire par exemple une copie amputée du premier élément A[1 :], ou du dernier élément A[:—1], ou en ordre inverse A[:: —1]. Par exemple, le code suivant crée une matrice M dont toutes les lignes sont les mêmes, et la modification de M[0][0] modifiera toute la première colonne de M.  M = [[(0]*10]*10  On peut initialiser une matrice carrée correctement d’une des manières suivantes.  [[G]*10À}8r - in range(16)] [[O for j in range(10)] for i in range(10)]  M1 M2  Une manipulation aisée de matrices est possible avec le module numpy mais nous avons choisi de ne pas en tirer profit dans cet ouvrage, afin d’obtenir un code géné- rique qu’on peut facilement traduire en Java ou C++. Une autre erreur typique concerne l’utilisation de la fonction range. Par exemple, le code suivant traite les éléments du tableau A entre les indices 0 et 9 inclus dans l’ordre.  for i in range(0, 10): #Vûrînclus, 10 exclu traite(A[i])  Pour traiter les éléments dans l’ordre décroissant, il ne suffit pas d'inverser les argu- ments. Car range(10, 0, -1) — le 3° argument indique le pas — est la liste des éléments de 10 (inclus) à O (exclu). Il faut donc écrire le traitement ainsi :  for i ià iange(9, -1, -1): # 9 inclus, -1 exclu traite(A[i])  1.3 Entrées-sorties  1.3.1 Lire l’entrée standard  Pour la plupart des problèmes issus des concours de programmation, les données sont à lire depuis l’entrée standard, et la réponse à afficher sur la sortie standard. Si le fichier d’entrée s'appelle test.in par exemple et votre programme prog.py, vous pou- vez diriger le contenu du fichier vers votre programme par la commande suivante, à lancer dans une console :  python prog.py < test.in 
1.3. ENTRÉES-SORTIES [ 13  Généralement sous Mac OS X, une console peut être obtenue en > tapant Commande-Espace Terminal et sous Windows, via Dé- marrer > Exécuter —> cmd. Si vous êtes sous Linux, le raccourci clavier est Alt-F2, mais ça, vous le saviez déjà.  Si vous souhaitez enregistrer la sortie de votre programme dans un fichier intitulé test.out, tapez :  python prog.py < test.in > test.out  Petite astuce, si vous souhaitez afficher la sortie en même temps que vous l’en- registrez dans un fichier test.out, tapez la ligne suivante (la commande tee n’est pas présente par défaut sous Windows) :  python prog.py < test.in | tee test.out  L'entrée est à lire ligne par ligne via la commande input(), qui renvoie la pro- chaine ligne de l’entrée sous la forme d’une chaîne de caractères, en excluant le ou les caractères de fin de ligne!. Il existe dans le module sys une fonction similaire stdin.readline(), qui ne supprime pas les marqueurs de fin de ligne, et qui est 4 fois plus rapide selon nos expériences. Si la ligne lue est censée contenir un entier, on convertira la chaîne avec la fonc- tion int (si c’est un nombre à virgule, on utilisera float). Dans le cas où elle contient plusieurs entiers séparés par des espaces, on découpe d’abord la chaîne en diffé- rentes parties avec split(), qu’on convertit toutes en entiers avec la méthode map. Par exemple, dans le cas de deux entiers hauteur et largeur à lire sur la même ligne, séparés d’un espace, la commande suivante convient :  import sys  hauteur, largeur = map(int, sys.stdin.readline().split())  Si votre programme a des problèmes de performance lors de la lecture, vous pourrez gagner un facteur 2 selon nos expériences en lisant toute l’entrée avec un seul appel système. L'instruction suivante suppose que l’entrée n’est composée que d'entiers, éventuellement sur plusieurs lignes. Le paramètre 0 à la fonction os.read indique le flux d’entrée standard, et la constante M doit être une borne supérieure sur la taille du fichier. Par exemple si celui-ci contient 10” entiers chacun entre 0 et 10°, alors comme chaque entier s’écrit sur au plus 10 caractères et qu’il y a au plus 2 caractères de séparation entre les entiers (\n et \r ), on peut choisir M = 12 - 10°.  import os  entree = list(map(int, os.read(0, M).split(9))  1. Suivant les systèmes d'exploitation, la fin de ligne est codée par les caractères |r, \n ou les deux, mais c’est sans importance lors d’une lecture avec input(). Notez aussi qu’en Python 2 le comportement de input() est différent, aussi il convient d’utiliser la fonction équivalente raw_input() 
14 CHAPITRE 1. INTRODUCTION  Exemple - lire trois matrices À, B, C et tester si AB = C_ Dans cet exemple, l'entrée est de la forme suivante : une ligne contenant un unique entier n, suivie de 3n lignes contenant chacune n entiers séparés par des espaces. Ces lignes codent les valeurs contenues dans trois matrices n x n A, B,C données ligne par ligne. Le but est de tester si le produit de A par B est égal à la matrice C. Une approche directe aura une complexité O(n°), par la multiplication de matrices. Mais il existe une solution en O(n°) probabiliste, qui consiste à choisir au hasard un vecteur x et à tester si A(Bx) = Cx (c'est le test de Freivalds [8]). Quelle est la probabilité que la procédure affiche égalité alors que AB # C ? Si jamais les calculs se font modulo d, la probabilité d'erreur est au plus 1/d. Cette probabilité d’erreur peut être rendue arbitrairement petite en répétant le test plusieurs fois. Le code suivant a une probabilité d'erreur majorée par 1076. | from random import randint | from sys import stdin  \ def readint (): | return int(stdin.readline ())  \ def readarray(typ): return list(map(typ, stdin.readline().split O)) | âdef readmatrix(n): | M = (] for _ in range(n): row = readarray(int) assert len(row) == n { M.append(row) return M  Îdef mult(M, v): | n = len(M) return [sum(M[i][j] * v[j] for j in range(n)) for i in range(n)]  | de£ freivalds(A, B, C:  n = len(A) Î x = [randint(0, 1000000) for j in range(n)] Î return mult(A, mult(B, x)) == mult(C, x) if __name__ == "__main__":  n = readint()  A = readmatrix(n) B = readmatrix(n) C = readmatrix(n)  print(freivalds(A, B, C)) 
1.4. COMPLEXITÉ . 15  1.3.2 Format de l’affichage  Les réponses de votre programme devront être affichées avec la commande print, qui produit une nouvelle ligne avec les valeurs de ses arguments. La production du caractère de fin de ligne peut être annulée en passant end=" en argument. Pour afficher des nombres flottants avec une précision fixée, on utilisera l’opérateur %, comme dans fmt%val. Le premier argument est une chaîne contenant des marqueurs d'affichage débutant par %. Le i-ème marqueur sera remplacé par le ;-ème élément dans val. L'exemple suivant affiche une ligne de la forme "Case #1 : 51.10% Paris”.  print( "Case #%i: %.02f %s" % (testCase, pourcentage, ville) )  Le %i est remplacé par la valeur de l’entier testCase, le %.02f par la valeur du nombre flottant pourcentage avec 2 chiffres derrière la virgule, et %s par la valeur de la chaîne de caractères ville.  1.4 Complexité  Pour écrire un programme efficace, il faut d’abord trouver un algorithme de bonne complexité. Celle-ci est exprimée en fonction de la taille de l’entrée. Pour pouvoir comparer facilement des complexités, on utilise la notation de Landau. On dit par exemple que la complexité d’un algorithme est O(n*) si le temps de calcul peut être majoré par une fonction quadratique en n, où n représente la taille ou un paramètre de l’entrée. Concrètement pour deux fonctions f, g on dénote f € O(g) s’il existe des constantes positives ny,c telles que pour tout n > #p On ait f(n) < c - g(n). Par abus de notation, on écrit aussi f = O(g). Cette notation permet de faire abstraction des constantes multiplicatives et additives dans une fonction f et faire ressortir l’ordre de grandeur et la forme de dépendance d’'un paramètre. De manière similaire si pour des constantes no, c > 0 on a f(n) < c - g(n) pour tout n > , alors on note f € \(g). Si f € O(g) et f € Omega(g) alors on note f € ©(g), qui indique que f et g sont de même ordre de grandeur. Si la complexité d’un algorithme est O(n°) pour une constante c, on dit qu'il est polynomial en n. Un problème pour lequel il existe un algorithme polynomial est dit polynomial, et la classe de ces problèmes porte le nom P. Malheureusement, tous les problèmes ne sont pas polynomiaux, et il existe de nombreux problèmes pour lesquels aucun algorithme polynomial n’a été trouvé à ce jour. Un de ces problèmes est k-SAT : « Étant donné n variables booléennes et m clauses comportant chacune k littéraux (variable ou sa négation), est-il possible d’affecter chaque variable à une valeur booléenne de façon que chaque clause contienne au moins un littéral de valeur Vrai ? » (SAT étant la version de ce problème sans restric- tion sur le nombre de variables.) La particularité de chacun de ces problèmes est que l’on peut vérifier en temps polynomial si une solution potentielle (affectation aux variables) satisfait toutes les contraintes, en évaluant toutes les clauses : ils sont dans la classe NP. On peut facilement résoudre 1-SAT en temps polynomial, donc 1-SAT est dans P. 2-SAT est également dans P, c’est l’objet de la section 6.10 page 92. Mais à partir de 3-SAT, on ne sait pas. On sait seulement que résoudre 3-SAT est au moins aussi difficile que résoudre SAT. 
16 CHAPITRE 1. INTRODUCTION  Il se trouve que P c NP, lintuition étant que si on peut construire une solution en temps polynomial, alors on peut construire une solution en temps polynomial. On pense que P # NP, mais cette conjecture reste sans preuve encore à ce jour. En attendant, les chercheurs ont relié les problèmes NP entre eux par des réductions, qui permettent de transformer en temps polynomial un algorithme pour un problème A en un algorithme pour un problème B. Ainsi, si A est dans P, alors B est aussi dans P : A est «au moins aussi difficile » que B. Les problèmes au moins aussi difficiles que SAT constituent la classe des problèmes NP-difficiles, et parmi eux on distingue les problèmes NP-complets, à la fois NP-difficiles et dans NP. Résolvez- en un seul en temps polynomial et vous les résoudrez tous, et serez gratifié d’une reconnaissance éternelle, accompagnée d’un million de dollars. Actuellement, pour résoudre ces problèmes dans un temps acceptable, il faut soit se restreindre à des instances avec des propriétés qui pourraient aider la résolution (planarité d’un graphe par exemple), ou autoriser le programme à répondre correctement avec seulement une probabilité constante, ou produire une solution proche d’une solution optimale. Heureusement les problèmes rencontrés dans les concours de programmation sont en général polynomiaux. Si ces questions de classes de complexité vous intéressent, nous vous recommandons la lecture du cours de complexité algorithmique de Sylvain Périfel [24]. Pour les concours de programmation en particulier, vos programmes doivent donner la réponse en quelques secondes, ce qui laisse aux processeurs actuels le temps d’effectuer des calculs de l’ordre d’une dizaine ou centaine de millions d’opérations. Le tableau suivant donne un ordre d’idée des complexités acceptables pour un temps de réponse d’une seconde. Ces nombres sont à prendre avec précaution et dépendent bien sûr du langage ! de la machine qui va exécuter le code, et du type d’opération (opérations sur des entiers, sur des flottants, appels à des fonctions mathématiques).  tailleentrée complexité acceptable 1000000 O(n) 100000 O(nlog n) 1000 O(n?)  Le lecteur est invité à faire des expériences avec des programmes simples pour tester le temps nécessaire à effectuer n multiplications d’entiers pour différentes valeurs de n. Nous insistons ici sur le fait que les constantes cachées dans la notation de Landau peuvent être importantes, et que parfois en pratique, un algorithme de complexité asymptotique plus grande est à préférer. Un exemple est celui de la multiplication de deux matrices n x n. La méthode naïve coûte O(n*) opérations, tandis qu’une procédure récursive découverte par Strassen [26] coûte seulement O(n*#"). Mais pour les tailles de matrices que vous aurez à manipuler, la méthode naïve sera sans doute plus efficace. En Python, l'ajout d’un élément à une liste se fait en temps constant, ainsi que l’accès d’un élément à un indice donné. La création d’une sous-liste par L[i : j] se fait en temps O(max{1, j — 1}). Les dictionnaires sont représentés par des tables de  1. Considérez grossièrement Java deux fois plus lent et Python quatre fois plus lent que C++. 
Hh rs  e  v rse m  rr st sqq  ce e  1.5. TYPES ABSTRAITS ET STRUCTURES DE DONNÉES ESSENTIELLES 17  hachage, et l’accès à une clé se fait en temps linéaire dans le pire cas (en le nombre de clés du dictionnaire), mais en pratique en temps constant. Cette constante par contre n’est pas négligeable, donc si les clés de votre dictionnaire sont les entiers de 0ä n—1, préférez l’utilisation d'un tableau. Pour certaines structures de données, on utilise la notion de complexité en temps amorti. Par exemple, une liste en Python est représentée en interne par un tableau, muni d’une variable taille stockant la taille. Lorsqu’un nouvel élément est ajouté au tableau avec la méthode append, il est rangé dans le tableau à l’indice indiqué par la variable taille, puis la variable taille est incrémentée. Si la capacité du tableau ne suffit plus, un nouveau tableau de taille 2 fois plus grand est alloué et le contenu recopié. Ainsi, lors de n appels successifs à append sur une liste initialement vide, le temps de chaque appel est parfois constant, parfois linéaire en la taille de la liste. Mais la somme des temps de ces appels est O(n), ce qui réparti sur chacun des appels donne un temps amorti O(1).  1.5 Types abstraits et structures de données essentielles  Nous allons à présent aborder un thème au cœur de la notion de programmation efficace : les structures de données sur lesquelles nos programmes reposent pour résoudre des problèmes. Un type abstrait est une spécification des valeurs possibles que peut prendre un ensemble d’objets, des opérations que l’on peut effectuer sur ces objets et du comportement de ces opérations. On peut donc voir un type abstrait comme un cahier des charges. Une structure de données est une façon concrète d’organiser des données pour pou- voir les traiter efficacement, selon les clauses spécifiées par un cahier des charges. Ainsi on peut implémenter un type abstrait par une ou plusieurs structures de don- nées , et déterminer la complexité en temps et mémoire de chaque opération. Ainsi, selon la fréquence des opérations qu’on sera amené à effectuer, on préférera choisir telle ou telle implémentation d’un type abstrait pour répondre à un problème donné. Pour bien coder, il faut maîtriser les structures de données offertes par le langage et la bibliothèque standard qui l’accompagne. Nous passons en revue dans cette section les structures de données les plus utiles aux compétitions de programmation.  1.5.1 Pile  Une pile (voir figure 1.2) est un objet qui maintient un ensemble d’éléments et propose les opérations suivantes : tester si la pile est vide, ajouter un élément sur le dessus de la pile (empiler), accéder à l’élément au-dessus de la pile et le supprimer (dépiler). La liste de base de Python réalise une pile. On empile avec la méthode append(élément) et on dépile avec la méthode pop(). Si une liste est utilisée comme un booléen, par exemple comme condition d’une instruction if ou while, elle vaut Vrai si et seulement si elle n’est pas vide. D’ailleurs c’est le cas pour toutes les objets implémentant la méthode __len__. Toutes ces opérations se font en temps constant. 
18 CHAPITRE 1. INTRODUCTION  pile S SRS S Frcure 1.2 - Les 3 structures de données prin- … Me _ cipales à accès séquentiel fournies par Python. _— file à 2 bouts  1.5.2 Dictionnaire  Un dictionnaire permet d’associer des valeurs à des clés, à la manière d’un tableau qui associe des valeurs à des indices. Le fonctionnement interne est basé sur une table de hachage, qui utilise une fonction de hachage pour associer aux éléments un indice dans une table, et implémente un mécanisme de gestion des collisions dans le cas où différents éléments sont envoyés sur le même indice. Dans le meilleur des cas, les opérations de lecture et d’écriture d’un dictionnaire se font en temps constant, mais dans le pire cas elles s’effectuent en temps linéaire s’il faut parcourir un nombre linéaire de clés pour gérer les collisions. En pratique, ce cas dégénéré n’arrive que rarement, et dans cet ouvrage on fera généralement l'hypothèse du temps constant pour les opérations d’accès à un dictionnaire. Si les clés sont de la forme 0,1,...,n—1, il est toujours préférable pour la performance d’utiliser un simple tableau.  1.5.3 File  Une file est similaire à une pile, à la différence près qu’on ajoute des éléments en fin de file (enfiler) et qu’on les retire en début de file (défiler). Elle est aussi appelée file FIFO pour (first in, first out, comme une file d’attente) tandis que la pile est appelée LIFO (last in, first out, comme une pile d’assiettes). Dans la bibliothèque standard de Python, il existe deux classes implémentant une file. La première Queue est une implémentation synchronisée, ce qui signifie que plusieurs processus peuvent y accéder simultanément. Comme les codes de ce livre n’exploitent pas le parallélisme, l'usage de cette classe n’est pas recommandé, car elle entraîne un ralentissement dû à l’utilisation de sémaphores pour la synchronisation. La deuxième classe est deque (pour double-ended queue), qui propose en plus des méthodes d’ajout en fin de file (append(element) et de retrait en tête de file (popleft()), des méthodes d’ajout en tête de file (appendleft(element)) et de retrait en fin de file (pop()). On parle alors de file à deux bouts. Cette structure plus sophistiquée est utile en section 8.2 page 105 pour la recherche d’un plus court chemin dans un graphe dont les arêtes sont de poids 0 ou 1. Nous recommandons l‘usage de deque, mais à titre d'exemple nous illustrons ici comment implémenter une file avec deux piles. L'une correspond à la tête pour l’extraction, l'autre à la queue pour l’insertion. Lorsque la pile de tête est vide, elle est échangée avec la pile de queue. L'opérateur__len__ permet de récupérer par len(q) le nombre d’éléments dans la file q et le tester si elle est non vide par if q, heureusement en temps constant. 
1,5. TYPES ABSTRAITS ET STRUCTURES DE DONNÉES ESSENTIELLES 19  Ëclass OurQueue : def __init__(self): self.in_stack = [] # la queue self.out_stack = [] # la tête  Ï def __len__ (self): | return len(self.in_stack) + len(self.out_stack)  def push(self, obj): self.in_stack.append(obj)  def pop(self): if not self.out_stack: # la tête est vide self,.out_stack = self.in_stack[::-1] self.in_stack = [] return self.out_stack.pop()  1.5.4 File de priorité et tas  Une file de priorité est un type abstrait permettant d’ajouter des éléments et de retirer un élément de clé minimale. Elle s’avère utile par exemple dans le tri d’un tableau (tri par tas), dans la construction d'un code de Huffman (voir section 10.1 page 146) et dans la recherche d’un plus court chemin entre deux nœuds d’un graphe (voir algorithme de Dijkstra section 8.3 page 106). Elle est habituellement implémen- tée par un tas, une structure de données sous la forme d’un arbre.  Arbres binaires parfaits et quasi parfaits  Un arbre binaire est parfait si toutes ses feuilles sont à même distance de la racine. Il est quasi parfait si toutes ses feuilles sont sur au plus deux niveaux, le moins profond étant complet et les feuilles du plus profond niveau le plus regroupées à gauche possible. Il convient de représenter un tel arbre par un tableau, voir figure 1.3. L'élément à l’indice 0 de cet arbre est ignoré. La racine se trouve à l’indice 1, et les deux fils d’un nœud / sont 2i et 2i + 1. De simples manipulations des indices permettent de descendre ou de remonter dans l’arbre. D’autres représentations sont données en début de section 10 page 145.  File de priorité et tas  Un tas (heap en anglais, aussi appelé arbre tournoi) est un arbre vérifiant la pro- priété de tas : chaque nœud a une clé plus petite que chacun de ses fils, pour une certaine relation d’ordre. La racine d'un tas est alors l’élément de clé minimale. Il existe une variante appelée ftas-max, où chaque nœud a une clé plus grande que chacun de ses fils. On s’intéresse généralement aux tas binaires, qui sont des arbres binaires tournois quasi parfaits. Cette structure de données permet d’extraire l’élément le plus petit et d'insérer de nouveaux éléments avec un coût logarithmique, ce qui est avantageux. Généralement, les objets considérés sont des éléments quelconques munis d’une relation d’ordre. Un tas peut aussi permettre la mise à jour de la priorité d’un élément, 
20 CHAPITRE 1. INTRODUCTION  FiGURE 1.3 — Un arbre bi- naire quasi parfait et sa re- présentation par tableau.  0 1:2:3 4.5 6 / & 9 1011.12  opération utile pour l'algorithme de Dijkstra (voir section 8.1) lorsqu'un chemin plus court a été découvert vers un sommet. En Python, les tas sont implémentés par le module heapq. Ce module fournit des fonctions pour transformer un tableau en tas (heapify(tableau)), qui reste alors un tableau codant un arbre quasi parfait comme décrit dans la section précédente, sauf que l’élément à l’indice 0 ne reste pas vide et contient la racine. Le module permet aussi d’insérer un nouvel élément (heappush(tas,élément)) et d’extraire le minimum (heappop(tas)). En revanche, ce module ne permet pas de changer la valeur d’un élément dans le tas, opération utile pour l’algorithme de Dijkstra pour améliorer la complexité en temps. Nous vous proposons alors l’implémentation suivante, plus complète.  Détails d’implémentation La structure contient un tableau heap qui stocke le tas proprement dit, ainsi qu’un dictionnaire rank qui permet de retrouver l'indice d'un élément stocké dans le tas. Les principales opérations sont push et pop. Un nouvel élément est inséré avec push et il est ajouté comme dernière feuille dans le tas, puis le tas est réorganisé pour respecter l’ordre de tas. L'élément minimal est extrait avec pop, la racine est remplacée par la dernière feuille du tas, puis le tas est réorganisé pour respecter l’ordre de tas, voir figure 1.4. L'opérateur __len__ renvoie le nombre d’éléments du tas. La présence de cet opérateur permet par le fonctionnement de Python de convertir implicitement un tas en booléen et d'’effectuer des tests de la forme par exemple while h pour boucler tant que le tas h n’est pas vide. La complexité moyenne des opérations de notre tas est O(log n), mais la com- plexité dans le pire cas est O(n) dû à l’utilisation d’un dictionnaire (rank). 
1.5. TYPES ABSTRAITS ET STRUCTURES DE DONNÉES ESSENTIELLES 21  Fiaure 1.4 — L'opération pop enlève et renvoie la valeur 2 du tas et la remplace par la dernière feuille 15. Puis l'opération down effectue une série d’échanges pour placer 15 à une position qui res- pecte l’ordre du tas.  @ © © ©  class OurHeap: def __init__(self, items): self.n = 0 self.heap = [None] # index 0 will be ignored self.rank = {} for x in items: self.push(x)  def __len__(self): return len(self.heap) - 1  def push(self, x): assert x not in self.rank i = len(self.heap)  self.heap.append(x) # ajout d’une nouvelle feuille self.rank[x] = i self.up(i) # maintenir l’ordre de tas  def pop(self): root = self.heap[1] del self.rank[root]  x = self.heap.pop() # enlever la dernière feuille | if self: # le tas n’est pas vide self.heap[1] = x # et la mettre à la racine self.rank[x] = 1  self.down(1) # maintenir l’ordre de tas return root 
22 CHAPITRE 1. INTRODUCTION  La réorganisation se fait par les opérations up(i) et down(i), qui sont appelées lorsque l’élément à l’indice / pourrait être trop petit par rapport à son père (pour up), ou trop grand pour ses fils (pour down). Ainsi, up effectue une suite d'échanges d’un nœud avec son père en remontant dans l’arbre jusqu’à ce que l’ordre de tas soit respecté. L'action effectuée par down est similaire, pour un échange entre un nœud et le fils de plus petite valeur. Finalement, la méthode update permet de changer la valeur d’un élément du tas. Elle appelle ensuite up ou down pour préserver l’ordre du tas. C’est pour cette méthode qu'il fallait introduire le dictionnaire rank.  | def up(self, i): | x = self.heap[i] | while i > 1 and x < self.heap[i // 21]: self.heap[i] = self.heap[i // 2] self.rank[self.heap[i // 211 = i i //= 2 self.heap[i] self.rank[x]  x # point d’insertion trouvé i  x = self.heap[i] n = len(self.heap) while True: left = 2 *i # descendre dans l’arbre right = left + 1 if right < n and \ self.heap[right] < x and self.heap[right] < self.heap[left]: self.heap[i] = self.heap[right] | self.rank[self.heap[right]] = i # remonter fils droite | i = right | elif left < n and self.heap[left] < x: Ï self.heap[i] = self.heap[left] self.rank[self.heap[left]] = i # remonter fils gauche | | i = left | else: self.heap[i] self.rank[x] return ‘  | î def down(self, i): l | |  # point d'insertion trouvé  u -H  def update(self, old, new): Ï i = self.rank[old] changer la valeur à l’indice i | del self.rank[old] self.heap[i] = new self.rank[new] = i if old < new: # maintenir l’ordre de tas self.down(i) else: self.up(i)  $ 
1,5. TYPES ABSTRAITS ET STRUCTURES DE DONNÉES ESSENTIELLES 23  1.5.55 Union-find  Définition Union-find est une structure de données qui stocke une partition d'un univers V et qui permet les opérations suivantes, aussi appelées requêtes dans le contexte des structures de données dynamiques : — find(v) renvoie un élément canonique de l’ensemble contenant v. Pour tester si u etv sont dans le même ensemble, il suffit de comparer find(u) avec find(v). — union(u,v) fusionne l'ensemble contenant u avec celui contenant v.  Application La principale application de cette structure est de déterminer les com- posantes connexes d’un graphe, voir section 6.6 page 78. Chaque ajout d’arête va correspondre à un appel de union et find permet de tester si deux sommets sont dans la même composante. Union-find est également utilisé dans l’algorithme de Kruskal, qui détermine un arbre couvrant de poids minimal, voir section 10.4 page 153.  N /UN  {/sx\ ( 02 ( 10 ) x _< / “ î \ - — = 960600c 0000 0 \j / , \ / \.< \__/ \_< \/_/ \T/ J / \ / $ «3)( 1 (1) g'$}{s\fi\(®…o» 27 \_/ _ AN EN SN ! s; ?s \ — N  Ficure 1.5 — À gauche : La structure union-find pour un graphe ayant 2 com- posantes {7,8,12} et {2,3,4,5,6,9,10,11}. À droite : lors de l’appel à find(10) tous les sommets du chemin vers la racine pointent maintenant directement sur la racine 5. Ceci accélère de futurs appels à find pour ces sommets.  Structure de données en temps quasi constant par requête Nous organisons les éléments d’un ensemble en arbre orienté vers un élément canonique, voir figure 1.5. Chaque élément v a une référence parent[v] vers l’élément plus haut dans l’arbre. La racine v — l'élément canonique de l’ensemble — sera indiquée par une valeur spéciale dans parent[v] qu’on choisira : O, -1, où v lui-même, tant que c’est cohérent. Nous stockons aussi la taille de l’ensemble dans un tableau taille[v], où v est l’élément canonique. Il y a deux idées dans cette structure de données. 1. Lors du parcours d’un élément vers la racine, on en profite pour compresser le chemin, c’est-à-dire accrocher les éléments parcourus directement sous la racine. 2. Lors de l'union, on accroche l’arbre de plus petit rang sous la racine de l’arbre de plus grand rang. Le rang d’un arbre correspond à la profondeur qu'’il aurait eue si aucun chemin n’était compressé. 
24 CHAPITRE 1. INTRODUCTION  Ceci nous donne :  Ëc1ass UnionFind: | def __init__(self, n): | self.up = list(range(n)) self.rank = [60] #n  def find(self, x): if self.up[x] == x: return x else: self.up[x] = self.find(self.up[x]) return self.up[x]  def union(self, x, y): repr_x = self.find(x) repr_y = self.find(y)  if repr_x == repr_y: # déjà dans la même composante return False if self.rank[repr_x] == self.rank[repr_y]:  self.rank[repr_x] += 1 self.up[repr_y] = repr_x elif self.rank[repr_x] > self.rank[repr_y]: self.up[repr_y] = repr_x else: self.up[repr_x] = repr_y return True  | Î Î Î  On peut prouver que n‘importe quelle séquence de 1n opérations union ou find sur un univers de taille n coûte un temps O((m + n)æ(n)), où a est l’inverse de la fonction d’Ackermann, qui en pratique peut être considérée comme la constante 4.  1.6 Techniques  1.61 Comparer  En Python une comparaison entre des tuples utilise l’ordre lexicographique. Ceci permet par exemple de trouver à la fois la plus grande valeur dans un tableau et l’indice correspondant, en prenant le plus grand indice en cas d'égalité.  | max({îaù[i], i) for i in range(leflEïàb))j  Par exemple pour trouver l’élément majoritaire d’un tableau on peut utiliser un dictionnaire pour compter le nombre d’occurrences de chaque élément, et sélection- ner un élément majoritaire avec la technique ci-haut. L'implémentation que nous donnons a une complexité O(nk) en moyenne et O(n°k) dans le pire cas à cause de l’usage d’un dictionnaire, où n est le nombre de mots données en entrée et æ la longueur maximale d’un mot. Notez au passage l’utilisation du dictionnaire, stockant des couples (clé, valeur). Un dictionnaire vide est obtenu par {}, le test d'appartenance d’une clé se fait par in et not in. La boucle for permet d’itérer sur toutes les clés d’un dictionnaire. 
1.6. TECHNIQUES . 25  def maJor1ty(L) | compte = {} | for mot in L: ! if mot in compte: compte[mot] += 1 else: compte [mot] = 1 valmin, argmin = min((-compte[mot], mot) for mot in compte) ! return argmin |  1.6.2 Trier  Un tableau de n éléments en Python peut être trié en temps O(n log n). On dis- tingue deux tris : — un triavec la méthode sort() qui modifie la liste concernée (on dit qu'il est «en place »); — untriavec la fonction sorted() qui renvoie une copie triée de la liste concernée. Étant donné une liste L de n entiers distincts, on souhaite déterminer deux entiers dans L qui minimisent leur différence. On peut résoudre ce problème en triant L, puis lors d'un parcours, on sélectionne la paire la plus proche. Notez l'usage du minimum sur une collection de paire d’entiers, comparés par l’ordre lexicographique. Ainsi valmin contient la plus petite distance entre deux éléments successifs de L et argmin l'indice conespondant  |  Îdef closest_values(L):  assert len(L) >= 2 |  L.sort() valmin, argmin = min((L[i] - L[i - 1], i) for i in range(1, len(L))) % return L[argm1n - 1], L[argmin]  Trier n éléments nécessite (\(nlogn) comparaisons entre les éléments dans le pire des cas. Pour s’en convaincre, on considère une entrée de n entiers distincts. Un algorithme doit sélectionner un parmi n! ordres possibles. Chaque comparaison renvoie une valeur parmi deux possibles (plus grand ou plus petit) et divise ainsi l’espace de recherche en deux. Finalement il faut [10g,(n!)] comparaisons au pire pour identifier un ordre particulier, ce qui donne la borne inférieure O(log(n!)) = Q(n log n).  Variantes Dans certains cas on peut trier un tableau de n entiers en temps O(n), par exemple lorsqu'ils sont tous compris entre 0 et cn pour une constante c quelconque. Alors on peut par un simple parcours de l’entrée compter dans un tableau count de taille cn le nombre d’occurrences de chaque élément. Puis en parcourant count par indice croissant, on peut écrire dans un tableau de sortie les valeurs de 0 à cn en les répétant autant de fois que nécessaire. Cette technique porte le nom de tri comptage ou tri casier. ‘  1.6.3 Balayage  De nombreux problèmes en géométrie peuvent se résoudre avec la technique de balayage. Il en est de même pour beaucoup de problèmes portant sur des intervalles, 
26 CHAPITRE 1. INTRODUCTION  c'est-à-dire des objets géométriques à une dimension. L'idée est de parcourir les différents éléments de l’entrée de gauche à droite et de faire un traitement spécifique pour chaque élément rencontré.  Exemple - intersection d’intervalles Étant donné n intervalles [£;, :) pour i = 0,...,n — 1, on souhaite trouver une valeur x qui est incluse dans un maximum d'intervalles. Voici une solution en temps O(nlogn). On trie les extrémités, puis on les parcourt de gauche à droite avec un pointeur imaginaire x. Un compteur c permet de mémoriser le nombre d’intervalles dont on a vu le début, mais pas encore la fin, donc le nombre d’intervalles contenant x. Notez que l’ordre de traitement des éléments de B garantit que les fins d'inter- valles seront traités avant les débuts d’intervalles, ce qui est nécessaire lorsqu’on considère des intervalles semi-ouverts à droite.  def max_interval_intersec(S): B = ([(left, +1) for left, right in S] + [(right, -1) for left, right in S]) B.sort) c=0 best = (c, None) for x, d in B: c+d if best[0] < c: best = (c, x) return best  1.64 Algorithmes gloutons  Nous allons illustrer cette technique algorithmique importante que constituent les algorithmes gloutons. De manière informelle, de tels algorithmes produisent une solution pas à pas, en faisant à chaque étape un choix qui maximise un critère local. Il existe une notion formelle, reliée à des structures combinatoires appelés matroïdes, permettant de prouver l’optimalité ou la non-optimalité des algorithmes gloutons. Nous ne l’abordons pas dans cet ouvrage, voir [21] pour une bonne introduction.  Exemple - Produit scalaire minimal Nous introduisons la technique à l’aide d'un exemple simple. Étant donné deux vecteurs x et y composés de n entiers positifs ou nuls chacun, on souhaite trouver une permutation 71 de {1,… , n} telle que la somme  Z XiYn(i) î  soit minimale.  Application Soit n tâches à affecter à n travailleurs de manière bijective, c'est-à- dire que toute tâche doit être affectée à un travailleur différent. Chaque tâche a une durée en heures et chaque travailleur a un prix par heure. Le but est de trouver une affectation qui minimise le montant total à payer aux travailleurs. 
Lé. TECHNIQUES . 27  Algorithme en O(nlogn) Puisque appliquer une même permutation à x et à y préserve la solution optimale, sans perte de généralité on peut supposer que x est trié par ordre croissant. Nous prétendons qu'’il existe une solution qui multiplie x avec un élément maximal y;. Fixons un ordre 7 avec 77(0) = i et 7(k) = j pour un indice k et y; < y;. Nous observons que xoy; + x,y; est supérieur ou égal à xoy; + xzy;, ce qui implique que sans augmentation de coût, 71 peut être transformée tel que xp soit multiplié avec y;. L'observation est justifiée par le calcul suivant, qui nécessite que Xo, Xx Soient positifs ou nuls :  Xo $ Xk Xo(yj — Yi) $ Xk(Y; — Yi) XOYj — XoYi S XkYj — XkVi XoYyj + XkYi $ XoYi + XkYj-  En répétant l’argument sur le vecteur x amputé de xy et le vecteur y amputé de y;, nous trouvons que le produit est minimal lorsque ; — y7() est décroissante.  \def min_scalar_prod(x, y): ! x = sorted(x) # faire des copies y = sorted(y) # pour préserver les arguments return sum(x[i] * y[-i - 1] for i in range(len(x)))  1.6.55 Programmation dynamique  La programmation dynamique est une des méthodes qui doit faire partie de votre couteau suisse de programmeur. L'idée est de décomposer un problème en sous- problèmes, et de trouver la solution optimale du problème principal à partir des solutions aux sous-problèmes. Un exemple classique est le calcul du n-ième nombre de Fibonacci, défini par récurrence ainsi :  F(0)=0 F()=1 F() = F(i—1)+F(i -2).  Ce nombre calcule par exemple le nombre de possibilités de monter un escalier de n marches lorsqu’on peut prendre une ou deux marches à la fois. Un calcul de F par une fonction récursive est extrêèmement inefficace, car pour un même paramètre i, F(i) est calculé plusieurs fois, voir figure 1.6. Une solution par programmation dynamique consiste simplement à stocker dans un tableau de taille n + 1 les valeurs de F(0) à F(n), en le remplissant dans l’ordre croissant des indices. Ainsi, au moment du calcul de F(i), les valeurs F(i — 1) et F(i — 2) auront déjà été calculées. 
28 CHAPITRE 1. INTRODUCTION  F /F4\F L  e  2 /\ à R e Fe  n 5 \ÿ  FiGure 1.6 — À gauche, l’arbre d'exploration exhaustive du calcul récursif du nombre de Fibonacci F4. À droite, le graphe orienté acyclique des dépendances des valeurs de la programmation dynamique, qui a nettement moins de nœuds.  1.6.6 Coder des ensembles dans des entiers  Une technique pour traiter efficacement des ensembles d’entiers entre 0 et k pour un entier k de l’ordre ! de 63, consiste à les coder dans des entiers. Plus précisément, il s’agit d'utiliser la décomposition binaire d’un entier comme vecteur caractéristique du sous-ensemble à coder. Les codages et opérations sont exposés dans le tableau suivant. '  valeur codage explication {} 0 ensemble vide {i} 1<<; cette notation représente z {0,1,...,n—1} (1<<n)—-1 27—1=20+2!+...+2"! AUB A|B l’opérateur | est le ou binaire ANnB A&B l’opérateur & est le et binaire (A\B)JU(B\A) A"B l’opérateur ” est le ou exclusif ACB A&B== test d’inclusion ieA (1<<)&A _ test d'appartenance {min A} —A & À cette expression vaut 0 si À est vide  La justification de la dernière expression est donnée en figure 1.7. Elle est utile par exemple pour compter dans une boucle le cardinal d’un ensemble. Il n’existe pas d’expression équivalente pour obtenir le maximum d’un ensemble. Pour illustrer cette technique de codage, appliquons-la à un problème classique.  1. La limite vient du fait que les entiers en Python sont généralement codés dans un mot machine, qui habituellement est constitué de 64 bits aujourd’hui. 
1.6. TECHNIQUES , 29  On veut obtenir le minimum de l’ensemble {3,5, 6} codé en un entier. Cet entier  est 25 + 25 + 26 = 104. 64+32+8=104 01101000  complément de 104 10010111 -104 10011000  -104&104 =8 00001000 Le résultat est 2° codant le singleton {3}.  Ficure 1.7 — Exemple d’extraction du minimum d’un ensemble.  Exemple - Partage équitable en 3 parties  Définition On dispose de n entiers xo,...,x;-1 et on souhaite les partitionner en trois ensembles de même somme.  Algorithme naïf entemps O(2”") par exploration exhaustive. Il consiste à énumérer tous les sous-ensembles disjoints A, B C {0,...,n — 1} et de comparer les trois valeurs f(A), f(B), f(C) avec C = {0,...,n — 1} \ A \ B et f(S) = E;es X. L'implémentation fait l’économie de l’ensemble C en vérifiant que f(A) = f(B) et 3f(A) = f((0,...,n — 1}).  \ def three_partition(x): f = [0] * (1 << len(x)) for i in range(len(x)): for S in range(1 << i): f[S | ( << i)] = f[S] + x[i] for A in range(1 << len(x)): for B in range(1 << len(x)): if A & B == 0 and f[A] == f[B] and 3 * f[A] == f[-1]: return (A, B, ((1 << len(x)) -1) * A * B) return None  Pour une autre utilisation de cette technique, voir la résolution des chiffres dans les chiffres et les lettres, section 15.5 page 204. 1.6.7 Recherche dichotomique  Définition Soit f une fonction booléenne - dans {0, 1} - avec  f(0)<...< f(n—1)=1. On cherche le plus petit entier k tel que /(k) = 1.  Algorithme en temps O(logn) La solution est recherchée dans un intervalle [€, h], intialement € = 0,h = n—1. Puis f est testée au milieu de l’intervalle m = [(€+h)/2|.En fonction du résultat, l'espace de recherche est réduit soit à [€, m], soit à [m+1, h]. Notez que par l’arrondi vers le bas dans le calcul de m, le deuxième intervalle n’est jamais 
30 CHAPITRE 1. INTRODUCTION  vide, ni le premier d’ailleurs. La recherche termine au bout de [log,(n)] itérations, lorsque l’intervalle de recherche est réduit à un singleton.  | def discrete_binary_search(tab, lo, hi): while lo < hi: mid = lo + (hi - lo) // 2 if tab[mid]:  hi = mid else: lo = mid + 1  return lo  Bibliothèques La recherche dichotomique est proposée dans le module standard bisect, ainsi dans certains cas vous n’aurez pas à l’écrire vous-même. Considérons le cas d’un tableau tab composé de n éléments triés dont on souhaite trouver le point d’insertion d’un nouvel élément x. La fonction bisect_left(tab, x, O, n) renvoie le premier indice ; tel que tab[i] > x.  Domainecontinu Cette technique peut être appliquée également quand le domaine de f est continu et qu’on cherche la plus petite valeur xy avec f(x) = 1 pour tout x = x9. La complexité dépendra alors de la précision exigée de xo.  | def Ebntinuous_binary_search(f, lo, hi):  while hi - lo > le-4: # régler la précision voulue ici mid = (lo + hi) / 2. # division flottante if f(mid): hi = mid else: lo = mid return lo  Recherche sans borne supérieure connue Soit f une fonction booléenne monotone avec f(0) = O et la garantie qu'il existe un entier n tel que f(n) = 1. Le plus petit entier n9 avec f(no) = 1 peut être trouvé en temps O(log n9), même en l’absence de borne supérieure donnée pour 1. Initialement on pose n = 1, qu’on double tant que f(n) = 0. Une fois une valeur n trouvée avec f(n) = 1, on procède par la recherche dichotomique habituelle.  Recherche trichotomique Soit f une fonction sur {0,...,n — 1} croissante, puis décroissante, dont on souhaite trouver la valeur maximale. Dans ce cas il convient de diviser l’intervalle de recherche [/, h] non pas en deux mais en trois parties [/, a], [a + 1,b], [b + 1,h]. En comparant les valeurs de f(a) avec f(b), on peut décider lequel des intervalles [/, b], [a + 1,h] contient une valeur maximale. Le nombre d'itérations nécessaires est de nouveau logarithmique, de l’ordre de logz, n.  Recherche dans intervalle [0,2*) Dans le cas où la taille de l’espace de recherche n est une puissance de 2, il est possible d’améliorer légèrement la recherche dichoto- mique habituelle, en n’utilisant que des opérateurs de manipulation de bits, comme 
1,7, CONSEILS | 31  le décalage et le ou exclusif binaire. On commence par l’indice du dernier élement du tableau, qui en binaire s’écrit comme une suite de 1 de longueur k. Pour pour chacun des bits on teste si son remplacement par 0 résulte en un indice ; tel que tab[i] reste vrai. Dans ce cas, on procède au remplacement.  def optimized_binary_search(tab, logsize): hi = (1 << logsize) - l intervalsize = (1 << logsize) >> 1 while intervalsize > 0: if tab[hi * intervalsize]: hi = intervalsize intervalsize >>= 1l return hi  Inverser une fonction Soit f une fonction strictement monotone et continue. Il en existe donc une fonction inverse f7}, monotone également. Imaginons que f* soit bien plus facile à calculer que f, on peut s’en servir pour calculer f(x) pour une valeur x donnée. En effet, il suffit de trouver la plus petite valeur y telle que f7*(y) = x.  Exemple — remplissage de récipients Liquide nécessaire pour remplir système de vases communicants jusqu’à un niveau donnée Soit n récipients sous la forme de pavés droits à différentes hauteurs, reliés par un réseau de vases communicants. On verse dans ce système un volume V de liquide, et on souhaite déterminer le niveau résultant de l’eau dans les récipients.  %1evel = continuous_binary_search(lambda level: volume(level) >= V, 0, hi)  1.7 Conseils  Voici quelques conseils de bon sens pour vous aider à résoudre rapidement des problèmes algorithmiques et produire un programme correct. Soyez organisé et sys- tématique. Pour cela, il est important de ne pas se laisser emporter par l’envie de coder avant que tous les détails ne soient clairs. Il est facile de se lancer dans une implémentation d’une méthode qui ne peut pas aboutir pour une raison qui ne vous aurait pas échappé si vous aviez pris un peu de distance avant de prendre le clavier en main. Quand c’est possible, séparez dans votre programme la lecture de l’instance du calcul de la solution. Soyez sympathique avec vous-même et ajoutez systématique- ment dans les commentaires le nom du problème, si possible avec son URL et précisez la complexité de votre algorithme. Vous apprécierez cet effort lorsque vous revien- drez sur votre code plus tard. Surtout, restez cohérent dans votre code et réutilisez les termes donnés dans les énoncés pour bien montrer la correspondance. Il n’y a rien de pire que de devoir déboguer un programme où les noms des variables ne sont pas parlants. 
32 CHAPITRE 1. INTRODUCTION  Bien lire l'énoncé  Quelle complexité est acceptable ? Prêtez attention aux limites annoncées des ins- tances. Analysez votre algorithme avant de l'implémenter.  Quelles garanties sur les entrées ? Ne déduisez pas des garanties à partir de l'ex- emple. Ne supposez rien. S'il n’est pas dit que le graphe est non vide, il y aura probablement une instance avec un graphe vide. S'il n’est pas dit qu’'une chaîne de caractère ne contient pas d’espace, il y aura une instance avec une telle chaîne, etc.  Quel type de nombre choisir? Entier ou flottant ? Les nombres peuvent-ils être né- gatifs ? Si vous codez en C++ ou Java, déterminez une borne sur la plus grande valeur intermédiaire dans vos calculs, et décidez entre l’utilisation des entiers 16, 32 ou 64 bits.  Quel problème est facile ? Pour une compétition impliquant plusieurs problèmes, commencez par une lecture rapide pour déterminer le type de chaque problème (algorithme glouton, graphe implicite, programmation dynamique, etc.) et esti- mer son niveau de difficulté. Concentrez-vous alors sur les problèmes faciles en priorité. Pendant un concours en équipe, répartissez les problèmes suivant les domaines d’expertise de chaque participant. Surveillez aussi les progressions des autres équipes pour identifier les problèmes faciles à résoudre.  Bien planifier Comprenez l’exemple. Faites des schémas. Découvrez des liens avec des problèmes connus. Comment exploiter les particularités des instances ?  Quand c’est possible, utilisez la bibliothèque. Maîtrisez la classique recherche di- chotomique, le tri, les dictionnaires.  Utilisez les mêmes noms de variables que dans l’énoncé. De préférence des noms courts, mais parlants.  Initialisez vos variables. Assurez-vous que les variables soient bien réinitialisées avant le traitement de toute nouvelle instance. Marcher sur les restes de l’itéra- tion précédente est une erreur classique. Prenons par exemple un programme qui résout un problème de graphe dont l’entrée se compose du nombre d'’ins- tances, suivi de chaque instance débutant par deux entiers : le nombre de sommets n et le nombre d’arêtes m. Suivent ensuite deux tableaux d’entiers A, B de taille , codant les extrémités des arêtes pour chaque instance. Imagi- nons que le programme code le graphe sous forme de liste d'adjacence et pour chaque / = 0,..,m — 1, ajoute B[i] à G[A[i]] et A[i] à G[B[]]. Si les listes ne sont pas vidées à chaque début de lecture d'instance, les arêtes se cumuleront pour former une union de tous les graphes.  Déboguer Faites des erreurs maintenant pour avoir les bons réflexes plus tard.  Produisez des jeux de tests pour les cas limites (Wrong Answer) et pour les grandes instances (Time Limit Exceeded ou Runtime Error). 
1.8 POUR ALLER PLUS LOIN | 33  Expliquez l’algorithme et commentez le programme à un coéquipier. Vous devez être capable d’expliquer chaque ligne.  Simplifiez votre implémentation. Regroupez du code similaire.  Prenez de la distance en passant à un autre problème pour revenir avec un regard neuf.  Comparez l’environnement de votre machine en local avec celui du serveur sur lequel votre code sera testé.  1.8 Pouraller plus loin  Voici une sélection d’ouvrages complétant les sujets traités dans ce livre. — Un ouvrage de référence pour des algorithmes de base est le volumineux Introduction à l'algorithmique, cours et exercices de T. H. Cormen, C. E. Leiserson, R. L. Rivest et C. Stein, éditions Dunod 2010. — Pour des algorithmes plus spécialisés on fera référence à Encyclopedia of Algo- rithms, ouvrage collectif édité par Ming-Yang Kao, Springer Verlag 2008. — Les algorithmes de flots sont étudiés en long et en large dans Network Flows: theory, algorithms, and applications de R. K. Ahuja, T. L. Magnanti et J. B. Orlin édité chez Prentice Hall, 1993. — Une bonne introduction dans les algorithmes pour des problèmes de géomé- trie est le livre Computational Geometry: Algorithms and Applications de M. de Berg, O. Cheung, M. van Kreveld et M. Overmars, Springer Verlag 2008. — Une bonne introduction à la programmation en Python est Programmation avec le langage Python par Xavier Dupré, éditions Ellipses 2011. — D'’autres livres également très appréciés sont Python Essential Reference de David M. Beazley, édité chez Pearson Education en 2009 et Python cookbook écrit avec Brian K. Jones et édité chez O’Reilly Media en 2013. — Deux livres plus adaptés à la préparation pour des concours de programma- tion sont Competitive Programming par Steven et Felix Halim, auto-édition Lulu 2013 — Enfin, The Algorithm Design Manual de Steven S. Skiena, Springer Verlag 2009. Tout au long de cet ouvrage, des références renvoient à une entrée à la bibliogra- phie se trouvant dans les dernières pages. Celle-ci comporte des livres mais aussi des articles de recherche, qui généralement sont moins accessibles et destinés à un public spécialisé. Vous trouverez des articles de recherche soit par le site Google Scholar, soit dans une bibliothèque d'informatique universitaire. Ce livre est accompagné d’un site, tryalgo.org sur lequel vous trouverez les codes des programmes Python décrits dans ce livre, ainsi que des fichiers de test. La bibliothèque est disponible sur GitHub et PyPI, et peut être installée en Python 3 via pip install tryalgo. 
2. Chaînes de caractères  La résolution de problèmes sur des chaînes de caractères a été rapidement un domaine important en algorithmique. Ces problèmes concernent bien sûr le trai- tement des textes, comme la correction orthographique, la recherche de facteurs (sous-chaînes) ou plus généralement de motifs. Avec le développement de la bio- informatique de nouveaux problèmes d’alignement de chaînes ADN se sont posés. Ce chapitre présente une sélection d’algorithmes que nous considérons importants. Une chaîne de caractères pourrait être représentée en interne par une liste de sym- boles, mais en général on utilise le type adapté str, qui se comporte essentiellement comme une liste en Python. Les caractères peuvent être codés sur deux octets pour un codage Unicode, mais généralement ils sont codés seulement sur un octet, utilisant le codage ASCII : chaque entier entre 0 et 127 correspond à un caractère distinct, et les codes sont organisés de telle sorte que les symboles successifs "0” à "9", ’a’ à "z’ ou’A' à ‘Z' se suivent. Ainsi, si on dispose d’une chaîne de caractères s ne contenant que des capitales, on peut récupérer le rang de la i-ème lettre en calculant ord(s[i])-ord("A”). À l'inverse, la j-ème lettre de l’alphabet — numérotée à partir de 0 — est obtenue par chr(j+ord("A")). Lorsqu’'on parle de facteur (ou sous-chaîne) d’une chaîne, on force les caractères à être consécutifs, contrairement à la notion plus générale de sous-séquence (ou sous-mot).  2.1 Anagrammes  Définition Un motw est une anagramme d’un mot v s’il existe une permutation des lettres qui transforme w en v. Étant donné un ensemble de n mots de longueur au plus k, on veut détecter toutes les classes d'anagrammes.  entrée : le chien marche vers sa niche et trouve une limace de chine nue pleine de malice qui lui fait du charme  sortie : [une nue}, {[marche charme}, {chien chine niche}, {malice limace}.  Complexité La solution proposée résout ce problème en temps O(nklog k) en moy- enne, et en O(n°k log k) dans le pire cas dû à l’utilisation d’un dictionnaire.  Algorithme L'idée est de calculer une signature pour chaque mot, telle que deux mots sont anagrammes l’un de l’autre si et seulement s’ils ont la même signature. Cette signature est tout simplement un autre mot composé des mêmes lettres triées dans l’ordre lexicographique. 
36 CHAPITRE 2. CHAÎNES DE CARACTÈRES  La structure de données utilisée est un dictionnaire qui associe à chaque signature la liste des mots qui ont cette signature.  def anàgrams(w):  w = list(set(w)) # retirer les doublons d= {} # grouper les mots par même signature for i in range(len(w)): Ï s = ''.join(sorted(w[i])) # signature | if s in d: | d[s].appendCi) \ else: d[s] = [il # -- extraire anagrammes  reponse = [] for s in d: if len(d[s]) > 1: # ignorer mots sans anagramme reponse.append([w[i] for i in d[s]]) return reponse  2.2 T9-texte sur 9 touches  entrée: 2665687 sortie: bonjour  Application Les téléphones portables à touches proposent un mode de saisie inté- ressant, parfois appelé T9. Les 26 lettres de l’alphabet sont réparties sur les touches 2 à 9, comme en figure 2.1. Pour saisir un mot, il suffit de saisir la suite de chiffres correspondants. Mais comme plusieurs mots peuvent correspondre à une même sé- quence, il faut utiliser un dictionnaire pour proposer le mot le plus probable. À tout moment le téléphone affiche le préfixe du mot le plus probable correspondant à la séquence de nombres saisis.  Définition La première partie de l’instance du problème est un dictionnaire, com- posé de couples (m, w) où m est un mot sur l’alphabet de 26 lettres minuscules, et w est un poids d’importance du mot. La deuxième partie de l'instance est une série de séquences de chiffres de 2 à 9. Pour chaque séquence s, il faut afficher un mot du dic- tionnaire de poids maximal. Un mot m correspond à s si s est un préfixe de la séquence t obtenue de m en remplaçant chaque lettre par le chiffre correspondant, suivant la table de correspondance donnée en figure 2.1. Par exemple "bonjour" correspond à 26, mais aussi à 266 ou à 2665687.  Algorithme en O(nk) pour l’initialisation du dictionnaire, et en O(k) pour chaque requête. Ici n est le nombre de mots dans le dictionnaire et k une borne supérieure sur la longueur des mots. Dans un premier temps, on cherche pour chaque préfixe p d’un mot du dic- tionnaire le poids total de tous les mots de préfixe p. Ce poids sera stocké dans un dictionnaire freq. Puis dans un deuxième temps, on stocke dans un dictionnaire prop[seq] le préfixe qui sera proposé pour une séquence seq donnée. Un parcours sur 
22. T9- TEXTE SUR 9 TOUCHES ‘ 37  GHI JKL MNO EENE PQRS TUV [ L ls  les clés dans freq permet de déterminer le préfixe au plus grand poids. [ci l'ingrédient principal est une fonction mot_code qui pour un mot donné fournit la séquence de chiffres correspondante. Pour faciliter la lisibilité, l'implémentation ci-dessous est en O(nk*).  Frcure 2.1 - Les touches d'un téléphone portable.  it9 = "22233344455566677778889999" | # abcdefghijkImnopqrstuvwxyz correspondance  def lettre_chiffre(x): assert ’a’ <= x and x <= ’z’ | return t9[ord(x)-ordC'a')]  \ def mot_code(mot): return ’’.join(map(lettre_chiffre, mot))  def predictive_text (dico): freq = {} # freq[p] = poids total des mots de préfixe p for mot, poids in dico: prefixe = "" for x in mot: prefixe += x if prefixe in freq: fregq[prefixe] += poids else: Ï freq[prefixe] = poids | # prop[s] = préfixe à afficher sur s ‘ | prop = {} | for prefixe in freg: code = mot_code(prefixe) if code not in prop or freaq[prop[code]] < freq[prefixe]: prop[code] = prefixe return prop  def propose(prop, seq): if seg in prop: return prop[seq] else: return "None" 
38 CHAPITRE 2. CHAÎNES DE CARACTÈRES  2.3 Correcteur orthographique avec un arbre lexicographique  Application Comment stocker les mots d’un dictionnaire pour réaliser un correc- teur orthographique ? Pour un mot donné, on aimerait pouvoir rapidement trouver un mot proche du dictionnaire. Si l’on stockait les mots du dictionnaire dans une table de hachage, on perdrait toute information de proximité entre les mots. Il vaut mieux les stocker dans un arbre lexicographique, aussi appelé arbre de préfixes ou trie (à prononcer en anglais).  Définition Une trie est un arbre qui stocke un ensemble de mots. Les arcs d'un nœud vers ses fils sont étiquetés par des lettres distinctes. Chaque mot du dictionnaire correspond alors à un chemin de la racine vers un nœud de l’arbre. Les nœuds ont des marques pour distinguer ceux qui correspondent à des mots du dictionnaire de ceux qui sont seulement des préfixes stricts de mots du dictionnaire, voir figure 2.2.  ® o/\r a/\P tn - #  de d ®®® SE  FIGURE 2.2 — Un arbre lexicographique pour les mots as, port, pore, pré, près, prêt (mais sans les accents). Pour l'illustration, l’étiquette de l'arête est indiquée sur le nœud du fils. Les parenthèses marquent les nœuds correspondant à des mots du dictionnaire. À droite, une Patricia trie pour le même dictionnaire.  Correction orthographique Avec une telle structure, il est facile de chercher un mot du dictionnaire qui soit à distance dist d’un mot donné, pour la distance d'édition de Levenshtein définie section 3.2 page 52. Il suffit de simuler les opérations d'édition à chaque nœud, et d’effectuer les appels récursifs de la recherche avec le paramètre dist - 1.  Variante Il existe une structure plus compacte qui fusionne les nœuds tant qu’il y a un nœud avec un unique fils. Un nœud est alors étiqueté par un mot, plutôt que par une simple lettre, voir figure 2.2. Cette structure, optimale en mémoire et en temps de parcours, porte le nom de Patricia trie. 
2.3. CORRECTEUR ORTHOGRAPHIQUE  39  class Trie_Node: def __init__(self): self.isWord = False self.s = {c: None for c in ascii_letters}  def add(T, w, i=6): if T is None: T = Trie_Node() if i == len(w): T.isWord = True else: T.s[w[i]] = add(T.s[w[i]], w, i + 1) return T  def Trie(sS): T = None for w in S: T = add(T, w) return T  def spell_check(T, w): assert T is not None dist = 0  u = search(T, dist, w) if u is not None: return u dist += 1  def search(T, dist, w, i=60): if i == len(w): if T is not None and T.isWord and dist == 6: return "" else: return None if T is None: return None  if f is not None: return w[i] + f if dist == return None for c in ascii_letters: f = search(T.s[c], dist - 1, w, i) # insertion if f is not None: return c + f  if f is not None: return c + f return search(T, dist - 1, w, i + 1) # suppression  from string import ascii_letters # in python2 one would import letters  while True: # tente de distances de plus en plus grandes  f = search(T.s[w[i]], dist, w, i + 1) # correspondance  f = search(T.s[c], dist - 1, w, i + 1) # substitution 
40 CHAPITRE 2. CHAÎNES DE CARACTÈRES  24 Recherche de motifs - Knuth-Morris-Pratt  entrée: Jlalopalalali lala sortie: A  Définition Étant donné une chaîne s de longueur n et un motif t de longueur m, on souhaite déterminer le premier indice ; tel que t est un facteur de s à la position i. La réponse devrait être —1 si t n’est pas un facteur de s.  Complexité O(n +m), voir [19].  Algorithmenaïf Cela consiste à tester tous les alignements possibles de t en dessous de s et pour chaque alignement i vérifier caractère par caractère si t correspond à s[i.i + m — 1]. Il a une complexité de O(nm) dans le pire cas. L'exemple suivant montre les comparaisons effectuées par l'algorithme sur un exemple. Chaque ligne correspond à un choix de i, et indique les caractères du motif qui correspondent, ou un x en cas de différence.  |l a 1l 0 p a l a l a 1l i l  0 a x 1 x 2 l x 3 x + x 5 x 6 l a l a  On observe qu'après le traitement de i, on connaît déjà une bonne partie de la chaîne s. On pourrait utiliser cette information pour ne plus comparer #[0] avec s[1], comme dans l’exemple.  Algorithme Appelons le chevauchement de deux chaînes x, y le plus long mot qui est à la fois suffixe strict de y et préfixe strict de x. Au moment de détecter une différence entre s[i] et {[j] on pourrait alors décaler t de sorte ques[0… i—1] chevauche t. Comme le suffixe de s[0...i—1] est [0 j — 1] — les j dernières comparaisons ont montré égalité entre s[i — j..1—1] ett[0...j — 1] — le décalage qu’on va appliquer à t ne dépend que de t. On peut donc déterminer dans un pré-calcul ce décalage. On note par r[j] la valeur j moins le chevauchement entre f[0… j — 1] avec lui-même. L'implémentation est montrée ci-dessous. Pour analyser la complexité, on distingue la partie qui calcule r avec la recherche du motif. La première partie coûte ©(m) et la deuxième ©(n) par l’argument suivant. Chaque égalité s[i] = t[/] fait augmenter j de 1 et chaque inégalité décroît strictement j, car r[j] < j. Or il y à au plus n égalités, et comme / ne devient jamais négatif, le nombre d'inégalités est aussi majoré par n. 
2.5. BORDS MAXIMAUX — KNUTH-MORRIS-PRATT 41  def knuth_morris_pratt(s, t):  assert t != len_s = len(s) len_t = len(t) r = [0] * len_t  j = r[60] = -1 for i in range(!l, len_t): while j >= 0 and t[i - 1] != t[j]: j = r[j] j+= 1 r[i] = j j=0 | for i in range(len_s): | while j >= 0 and s[i] != t[j]: j =rlil j+:1 | if j == len_t: | return i - len_t + 1 return -l  Variantes Avec une petite modification et sans augmenter la complexité on peut produire un tableau de booléens p de taille H, qui indique pour chaque position ; si est un facteur de s à la position i. De façon plus générale, on peut calculer un tableau entier p qui détermine pour chaque position i le plus grand ; tel que le préfixe de t de longueur / est un facteur de s terminant en i. C’est l’objet de la section suivante.  2.55 Bords maximaux — Knuth-Morris-Pratt  Le problème de la recherche du bord maximal d’une chaîne permet de résoudre celui de la recherche du motifs et bien d’autres, exposés en fin de section. L'algo- rithme décrit ici repose sur les mêmes principes que l’algorithme de Knuth-Morris- Pratt, mais son implémentation est plus courte car repose sur de nombreuses astuces extrêmement élégantes.  Définition On appelle bord d’un mot w un mot à la fois préfixe strict et suffixe strict de w, et bord maximal de w noté B(w) son plus long bord. Par exemple, abaababa a pour bords aba, a et le mot vide e. Pour une chaîne w = w ---w,-1 donnée, on souhaite calculer les bords maximaux de chaque préfixe de w, ou de façon équivalente la longueur de ces bords, puisque tous les bords des préfixes de w sont des préfixes de w. On cherche donc à construire efficacement la suite des longueurs €; = |B(wo - - - w;-1)|-  Observation clé On observe une structure récursive dans la notion de bord : si u est bord de v qui est bord de w, alors u est également bord de w. L'application itérée de B à un mot w permet donc d’obtenir tous les bords de w. Par exemple, pour w = abaababa, on a B(w) = aba puis B(B(w)) = a et B*(w) = €.  Algorithme Supposons que l'on connaisse les bords maximaux pour les / premiers préfixes de w, c’est-à-dire jusqu'au préfixe u = w, ---w;_1. Considérons le préfixe 
42 CHAPITRE 2. CHAÎNES DE CARACTÈRES  suivant ux (pour plus de clarté, x dénote la lettre w;) : son bord maximal est forcément de la forme vx où v est bord de u, cf. figure 2.3. Notons v; = B/(u) le j-ième bord de u par taille décroissante et k; sa longueur. On va donc chercher v parmi la suite (v;) des bords de u, en commençant par le bord maximal v, = B(u). Pour savoir si un candidat v;x, déjà suffixe de ux, est bord de ux, il reste à vérifier que v;x est préfixe de ux. Or, v; est déjà préfixe de u (c’est un bord), donc il suffit de tester si la lettre qui suit immédiatement après v; (donc, à la position k; = |v;|) est x, ce qui revient à effectuer le test w,, =* x. Si c’est le cas, on a trouvé B(ux) = v;x et le calcul du bord maximal pour le préfixe ux est terminé. Sinon, on passe au bord suivant v;41 = P(v;) de taille k;41 = |B(v;)| = |B(wo - - - wx,-1)| = &, puisque v; n’est autre que le préfixe de w de longueur k;. Si aucune comparaison n’a été fructueuse, on sait que P(ux) = &. Comme à chaque itération, l’unique test qu’on effectue ainsi que le calcul de k;41 ne dépendent que de la longueur du bord en cours k;, notre implémentation n’utilise qu’une seule variable k pour déterminer le tableau €.  uUxT u T B(u) ? B(u) x B(ux)| v x v x  FIGURE 2.3 — Une étape de la variante de l’algorithme de Knuth-Morris-Pratt. Une fois les bords de u connus, on sait que le bord maximal de ux sera de la forme vx où v est bord de u. Si la lettre représentée par un point d’interrogation est un x, alors v = B(u), sinon il faut chercher v parmi les bords plus petits de B(u).  | def maximum_border_length (w) : n = len(w) L = [0] # (n + 1) for i in range(l, n):  k = L[i] while w[k] != w[i] and k > 0: k = L[Kk]  if w[k] == w[il: L[ic+:1)r=eskc+ 1 else: L[i + 1] = 6 return L  Complexité Curieusement, cet algorithme a une complexité amortie linéaire : en effet, le nombre d’itérations de la boucle while ne dépasse jamais la longueur du bord courant k, qui augmente au plus d’un à chaque itération de la boucle for.  Variantes On peut se servir de cette liste de bords maximaux pour résoudre de nombreux problèmes sur les mots : déterminer les facteurs carrés, les préfixes pa- lindromes, détecter si deux mots x et y sont conjugués (c’est-à-dire, s’ils s’écrivent 
2.5. BORDS MAXIMAUX — KNUTH-MORRIS-PRATT 43  us b-a.a b:asaib-a  n -L  B(u)|a b a a b a|  à bua àa- b «  FIGURE 2.4 — Connaître le bord maximal d’un mot périodique permet de déter- miner sa plus petite période. Si n est la longueur de u et si n — £, divise n, le mot est périodique et la plus grande valeur de k pour laquelle on à u = z* pour un mot z est n/(n — £).  x = uv et y = vu pour des mots u et v) ou encore la plus petite période d’un mot x, c'est-à-dire le plus grand k tel que x s’écrive z* pour un mot z.  Observation clé Si u est périodique, le bord maximal de u est exactement z** où k est le plus grand entier pour lequel il existe un mot z tel que u = z*, cf. figure 2.4.  ‘def powerstring_by_border (u) : L = maximum_border_length (u) n = len(u) if n % (n - L[-1]) == 0: return n // (n - L[-1]) return |  Application recherche du motif à danss On choisit une lettre # qui n’est ni dans t ni dans s et on s’intéresse à la longueur des bords maximaux des préfixes de t#s : une première remarque est que cette longueur ne peut jamais dépasser la longueur de t, à cause du caractère #. Mais à chaque fois que cette longueur atteint |t|, cela signifie qu’on a trouvé une occurrence de t dans s. On va donc déterminer par programmation dynamique la liste des €; = |B(u)| pour tous les préfixes u de t#s, cf. figure 2.5.  t#5abaaababaabaa  a b a a a b a a  Blt#s)|a b a a à b «- à  e[o 0 1 1KE: 23>2323  Ficure 2.5 — Une itération de l'algorithme de recherche des bords maximaux. À chaque occurrence de t dans s correspond un bord de longueur |t| dans le tableau des longueurs des bords maximaux €. 
44 CHAPITRE 2. CHAÎNES DE CARACTÈRES  Note Toutes les bibliothèques standard des langages de programmation proposent une fonction qui cherche un motif needle dans une chaîne haystack. Celle de Java 8 (2015) a une complexité en ©(nm) dans le pire cas, ce qui est étonnamment coûteux. Nous vous invitons à faire un test de temps de calcul en fonction de n pour la recherche de la chaîne 0”1 dans la chaîne 0?*.  2.6 Chaîne en puissance  entrée … sortie  abcd =(abcd)" aaaa =a*  ababab =(ab)*  Application On vous donne un échantillon d’un signal périodique, et il faut trouver la plus petite période. Ici le problème est simplifié dans la mesure où la longueur de l'entrée est toujours un multiple de la période.  Définition Étant donné une chaîne x, il faut trouver le plus grand entier k tel qu'il existe une chaîne y avec x = yX, qui est définie comme étant y concatené n fois. Il existe toujours une solution car x = x!. Une telle solution k satisfait que k divise la longueur m de x, et que pour p = m/k chaque caractère dans x soit égal au caractère p indices plus loin, et ceci quand x est vu comme une chaîne circulaire. Dans une chaîne circulaire x, le successeur du dernier caractère est définit comme étant le premier caractère de x. Une rotation de x supprime le premier caractère de x et l’ajoute à la fin. Si cette opération est appliquée autant de fois qu’il y a de caractères dans x, la chaîne x est transformé en x.  Algorithme en temps linéaire Le problème revient alors à trouver le plus petit p z 1, tel x est le résultat de p rotations de x. On utilise alors une astuce classique avec les chaînes circulaires : la solution est obtenue en cherchant la première occurrence de x dans xx (concaténation de x avec x), excluant la position 0 bien sûr, voir figure 2.6.  à b aa’°b ’aà a b-aja-b. aa b a a b a  a baabaaba  FIGURE 2.6 — La période 4 permet d'identifier x avec x soumis à une rotation de £ positions.  defÿ£g;;rsf}îng(x): | return len(x) // (x + x).find(x, 1) 
2.7. RECHERCHE DE MOTIFS — RABIN-KARP 45  2.7 Recherche de motifs —- Rabin-Karp  Complexité O(n + m) en espérance, O(nm) dans le pire cas.  Algorithme L'algorithme de Rabin-Karp [17] repose sur une idée complètement différente de celle de Knuth-Morris-Pratt. Pour chercher une chaîne t dans une grande chaîne s, on glisse une fenêtre de longueur len(t) sur s et on vérifie si le contenu de cette fenêtre est égal à t. Un test caractère par caractère est coûteux, alors on maintient plutôt une valeur de hachage pour le contenu de la fenêtre courante, qu’on compare avec la valeur de hachage de la chaîne à chercher t. Si jamais les valeurs de hachage coïncident, on procède alors au test coûteux caractère par caractère, voir figure 2.7. Pour obtenir une complexité intéressante, il faut pouvoir mettre à jour efficacement la valeur de hachage de la fenêtre glissée, on utilise alors ce qu’on appelle une fonction de hachage glissante. Si la fonction de hachage, à valeurs dans {0,1,...,p—1}, est bien choisie, on s’attend à ce qu’une collision — c’est-à-dire, que deux chaînes de caractères s, t de même taille distinctes tirées uniformément soient envoyées sur la même valeur de hachage — survienne avec une probabilité de l’ordre de 1/p. Dans ce cas la complexité moyenne de l’algorithme est O(n + m + m/p). Notre implémentation utilise p = 2°! — 1, donc en pratique la complexité est O(n + m), mais dans le pire cas, elle est de O(nm).  Î} Fraure 2.7 — L'idée de l'algo- ; bcd L _‘c\ rithme de Rabin-Karp est de  comparer d'abord les valeurs de hachage entre t et une fenêtre sur s avant de faire une comparaison coûteuse caractère par caractère.  s:1afcîefab'}ccabcdefacee  —  Fonction de hachage glissante Notre fonction de hachage transforme d’abord une chaîne de mm caractères en m entiers xo,..., Xm-1, COrrespondant à leur code ASCII, compris entre O et 127. Puis la valeur de la fonction de hachage est l’expression multilinéaire  h(xo … , Xm-1) = Xo * 1287* + x, - 12872 + .. + Xn * 128 + Xy-1 Mod p  où toutes les opérations sont effectuées modulo un grand nombre premier p. En pratique, il faut veiller à ce que toutes les valeurs calculées puissent être manipulées par une machine 64 bits, pour laquelle un mot machine (registre du processeur) est compris entre —2°° et 2* — 1. La plus grande valeur intermédiaire calculée par l’algorithme est 128 - (p — 1) = 2” - (p — 1), c’est pourquoi dans notre implémentation nous avons choisi p < 2°6, La forme polynomiale de cette fonction de hachage permet de calculer en temps constant la valeur de h(x1, .. , X) à partir de xo, Xn et h(xo … , Xn-1) : retirer le premier 
46 CHAPITRE 2. CHAÎNES DE CARACTÈRES  caractère revient à soustraire le premier terme, décaler les caractères vers la gauche revient à faire une multiplication par 128 et modifier le dernier caractère revient à ajouter un terme. En conséquence, décaler la fenêtre sur s, mettre à jour la valeur de hachage et comparer avec celle de t se fait en temps constant. Notez que décaler les caractères vers la droite revient à faire une multiplication par l’inverse de 128 modulo p, ce qui s'effectue également en temps constant. Notez dans le code ci-dessous l’ajout de la valeur DOMAIN * PRIME pour assurer que les calculs restent dans les entiers positifs ou nuls. Ce n’est pas nécessaire en Python, mais ça l’est dans d'autres langages tels que C++, un calcul de modulo pouvant alors renvoyer des nombres négatifs.  PRIME = 72057594037927931 # < 24{56} DOMAIN = 128  \def roll_hash(old_val, out_digit, in_digit, last_pos): val = (old_val - out_digit * last_pos + DOMAIN * PRIME) % PRIME val = (val # DOMAIN) % PRIME return (val + in_digit) % PRIME |  L'implémentation de l’algorithme débute par une fonction de comparaison ca- ractère par caractère des facteurs de longueur k dans s à la position i et dans t à la position /.  \ def matches(s, t, i, j, k): for d in range(k): if s[i + d] != t[j + d]: return False return True  Puis l'implémentation de l’algorithme Rabin-Karp proprement dit débute par le calcul des valeurs de hachage de t et de la première fenêtre dans s, pour ensuite boucler sur tous les facteurs dans s.  def rabin_karp_matching(s, t): hash_s = 0 hash_t = 0 len_s = len(s) len_t = len(t) last_pos = pow(DOMAIN, len_t - 1) % PRIME if len_s < len.t: return -l for i in range(len_t): # précalcul hash_s = (DOMAIN * hash_s + ord(s[i])) % PRIME hash_t = (DOMAIN * hash_t + ord(t[i])) % PRIME for i in range(len_s - len_t + 1):  if hash_s == hash_t: # vérifier caract. par caract. if matches(s, t, i, 6, len_t): return i  if i < len_s - len_t: hash_s = roll_hash(hash_s, ord(s[i]), ord(s[i+len_t]), last_pos) return -1 
2.7. RECHERCHE DE MOTIFS — RABIN-KARP 47  Cet algorithme est moins efficace que celui de Knuth-Morris-Pratt : nos expé- riences ont mesuré un facteur 3 dans le temps de calcul. Son intérêt par contre est que sa technique s'applique à plusieurs variantes intéressantes de ce problème.  Variante : recherche de multiples motifs L'algorithme de Rabin-Karp qui cherche si une chaîne t est un facteur d’une chaîne s donnée se généralise naturellement à un ensemble T de chaînes à chercher dans s, avec la garantie que toutes les chaînes de T ont la même longueur. Il suffit pour cela de stocker les valeurs de hachage des chaînes de T dans un dictionnaire to_search et pour chaque fenêtre de s de vérifier si la valeur de hachage associée est contenue dans to_search.  Variante : facteur commun Étant donné deux chaînes s, t et une longueur k, on cherche une chaîne f de longueur k qui soit facteur à la fois de s et de t. Pour résoudre ce problème, on considère d’abord tous les facteurs de longueur k sur la chaîne t. Ces chaînes sont obtenues de manière analogue à l'algorithme de Rabin-Karp, en faisant glisser une fenêtre de longueur k sur t et en stockant les valeurs de hachage correspondantes dans un dictionnaire pos. À chaque valeur de hachage obtenue, on associe la position de la fenêtre correspondante. Ensuite pour chaque facteur x de s de longueur k, on vérifie si sa valeur de hachage v est dans pos, auquel cas on compare caractère par caractère x avec les facteurs dans t aux positions de pos[v]. Pour cet algorithme il convient de faire attention au choix de la fonction de hachage. Si s et t sont de longueur n, il faudrait choisir p € Q(n°), pour que le nombre de collisions entre les valeurs hachage d’une des O(n) fenêtres de t avec une des O(n) fenêtres de s soit constant. Pour l’analyse précise, nous renvoyons le lecteur à [17].  Variante : facteur commun de longueur maximale Étant donné deux chaînes s, t, trouver le plus long facteur commun peut se résoudre par recherche dichotomique sur la longueur k en utilisant l’algorithme précédent. La complexité est O(n log m) où n est la longueur totale de s et de t, et 1n est la longueur du facteur optimal.  def rabin_karp_ factor(s. t. k) last_pos = pow(DOMAIN, k - 1) % PRIME | pos = {} assert k > 6 if len(s) < k or len(t) < k: return None | hash_t = 6 for j in range(k): # stocker les valeurs de hachage | hash_t = (DOMAIN * hash_t + ord(t[j])) % PRIME | for j in range(len(t) - k + 1): if hash_t in pos: pos [hash_t].append(j) else: pos[hash_t] = [j] i£f j < lenCt) - k: | hash_t = roll_hash(hash_t, ord(t[j]), ord(t[j + k]), last_pos) _ hash_rs = 6 for i in range(k): # précalcul hash_s = (DOMAIN * hash_s + ord(s[i])) % PRIME 
48 CHAPITRE 2. CHAÎNES DE CARACTÈRES  for i in range(len(s) - k + 1): if hash_s in pos: # est-ce que cette signature est dans s ? for j in pos[hash_s]: if matches(s, t, i, j, k): return Ci, j) if i < len(s) - k: | hash_s = roll_hash(hash_s, ord(s[i]), ord(s[i + k]), last_pos) | return None  28 Plus long palindrome d’une chaîne - Manacher  entrée: babcbabcbaccba sortie: abcbabcba  Définition Un mot s est un palindrome si le premier caractère de s est égal au dernier, le 2° est égal à l’avant-dernier et ainsi de suite. Le problème du plus long palindrome consiste à déterminer le plus long facteur qui soit un palindrome.  Complexité Ce problème peut-être résolu en temps quadratique par l'algorithme naïf, en temps O(nlog n) avec des tableaux de suffixes et en temps O(n) par l'algo- rithme de Manacher [23] que nous décrivons ici.  Algorithme D’abord, on transforme l'entrée s en insérant un séparateur # autour de chaque caractère et en ajoutant des sentinelles 4 et $ autour de la chaîne. Par exemple abc se transforme en *#a#b#c#$. Soit t le résultat. Ceci permet de traiter de manière égale les palindromes de longueur paire et de longueur impaire. Notez qu'avec cette transformation, tout palindrome commence et termine par un séparateur #. Ainsi, les extrémités d’un palindrome ont des indices de même parité, et ceci simplifie alors la transformation d’une solution sur la chaîne t en une solution sur la chaîne s. Les sentinelles simplifient le traitement des extrémités de la chaîne.  Le mot nonne contient un palindrome de longueur 2 (nn) et un de longueur 3 (non). Leurs équivalents dans  \fn#otnin#e#$ |--=1  commencent et terminent tous avec le séparateur #.  La sortie de l’algorithme est un tableau p qui indique pour chaque position i, le plus grand rayon r tel que le facteur de ; — r à i + r soit un palindrome. L'algorithme naïf est le suivant. Pour tout i, on initialise p[i] = O et on incrémente p[i] jusqu’à trouver le plus grand palindrome f[i — plil,…,i + p[i]] centré en :. 
2.8. PLUS LONG PALINDROME D'UNE CHAÎNE - MANACHER 49  [.s - -E À [. es s8 I — . ; Î î E T d j i à À =  Frcure 2.8 — L'algorithme de Manacher. On a déjà calculé p pour les indices < ;, et on veut calculer p[i]. Soit un palindrome centré en c de rayon d — c avec d maximal, et / le miroir de / par rapport à c. Par symétrie, le palindrome centré en j de rayon p[j] doit être égal au mot centré en i, au moins jusqu'au rayon d — i. En conséquence p[j] est une borne inférieure pour la valeur de p[i].  L'amélioration de Manacher concerne l’initialisation de p[i]. Supposons qu’on connaisse déjà un palindrome centré en c et de rayon r, donc terminant à droite en d = c+r.Soit j le miroir de / par rapport à c, voir figure 2.8. Il y a une relation forte entre p[i] et p[j]. Dans le cas où / + p[j] ne dépasse pas d, on peut initialiser p[i] par pLj]. Ceci est une opération valide, car le palindrome centré en j de rayon p[j] est inclus dans la première moitié du palindrome centré en c et de rayon d — c, donc il se trouve aussi dans la deuxième moitié. Après avoir calculé p[i] il faut mettre à jour c et d, pour préserver l'invariant qu'ils codent un palindrome avec d — c maximal. La complexité est linéaire, car chaque comparaison de caractères est responsable d’une incrémentation de d.  | def manacher (s): assert 'S’ not in s and ’4’ not in s and ’#’ not in s if s == "": return (6, 1)  t = "4#" + "#".join(s) + "#s" c=0 d =0 p = [6] # lenCt) for i in range(1, len(t) - 1): # -- refléter l'indice i par rapport à c mirror = 2 * c -i # =c-(i-c) p[i] = max(60, min(d - i, p{mirror])) # -- faire grossir le palindrome centré en i while t[i + 1 + p[i]] == t[i - 1 - p[i]1: p[i] += 1 # -- ajuster centre si nécessaire  if i + p[i] > d: e=i d = i+pl[i]l (k, i) = max((p[i], i) for i in range(1, len(t) - 1)) | return ((i - k) // 2, C(i + k) // 2) # extraire solution 
50 CHAPITRE 2. CHAÎNES DE CARACTÈRES  Application Une personne marche dans la ville. Son smartphone enregistre tous ses déplacements. On récupère cette trace et on cherche un certain type de trajet dans la journée, notamment des allers-retours entre deux endroits qui utilisent le même chemin. Pour cela, on extrait de la trace une liste de carrefours, et on y cherche des  palindromes. 
B Ÿ  3. Sêquences  Qu'est-ce que la programmation dynamique ? C’est une méthode de concevoir la résolution d’un problème comme une combinaison de solutions de certains de ses _ sous-problèmes. On calcule d’abord la solution des sous-problèmes et on la stocke _ pour pouvoir s’en resservir plus tard (principe de mémoisation), dans un ordre à fixer … dit balayage. Cette technique s’applique particulièrement bien à des problèmes sur des séquences, où les sous-problèmes sont définis par exemple sur les préfixes de la séquence.  - 3.1 Plus court chemin dans une grille  - Définition Considérons une grille de (n + 1) x (m + 1) cellules, numérotées (i, j) avec 0 < i <net0 < j < m. Les cellules sont reliées par des arcs pondérés : les … prédécesseurs d’une cellule (i, ÿ) sont (i — 1, j),(i — 1, j — 1) et (i, j — 1), excepté les cellules de la première colonne et première ligne (voir figure 3.1). Le but est de _ trouver un plus court chemin de (0,0) à (n, m).  Fiaure 3.1 — Plus court chemin dans une grille orientée, illustré par les arcs épais.  ë  Algorithme en O(nm) Comme ce graphe est orienté et acyclique (DAG en anglais, pour directed acyclic graph) on peut résoudre ce problème par programmation dyna- - mique, en calculant les distances de (0, 0) vers toutes les cellules (i, 7) dans un certain 
52 CHAPITRE 3. SÉQUENCES  ordre. D’abord les distances vers les cellules de la première ligne et première colonne sont faciles à calculer, car il existe un unique chemin vers ces cellules. Ensuite on calcule les distances vers les cellules (i, j) avec1 < i < n,1 < j < m dans l’ordre lexico- graphique. Ainsi la distance de (0, 0) vers (i, j) est le minimum sur trois alternatives, qui est déterminé en temps constant, car les distances vers les prédécesseurs ont déjà été calculées.  Variantes Plusieurs problèmes classiques se réduisent à ce simple problème, comme décrit dans les sections suivantes.  3.2 Distance d’édition de Levenshtein  entrée: AUDI, LADA sortie: LA-DA —AUDI 3 opér. : suppression L, insertion U, substitution A en I  Définition Étant donné deux chaînes de caractères x, y, on veut savoir combien d’opérations (insertion, suppression ou substitution) sont requises pour transformer x en y. Cette distance est utilisée par exemple dans la commande unix diff qui affiche un nombre minimal d’opérations ligne par ligne entre deux fichiers donnés.  Algorithme en O(nm) pour n = |x m = |y| par programmation dynamique, voir figure 3.2. On va calculer un tableau A[i, j] qui est la distance entre le préfixe de x de longueur ; et le préfixe de y de longueur j. Pour commencer, on peut initialiser A[O, j] = j et A[i, 0] = i. Puis en général quand i, j > 1, on a trois possibilités sur les dernières lettres des préfixes. Soit x; est supprimé. Soit y; est inséré (à la fin). Soit x; est remplacé par y; (s’il ne sont pas déjà égaux). Ceci donne la formule de récurrence suivante, où match est une fonction booléenne qui renvoie 1 si ses arguments sont identiques : A[i-1,j — 1] + match(xi, y;) A[i, j| = min4 A[ij—1]+1 A[i-1,j] +1.  Cette fonction code le coût d’une substitution de lettre, qui peut ainsi être ajusté. Par exemple le coût pourrait dépendre de la distance des lettres sur le clavier.  Séquence des opérations Pour calculer en plus de la distance d’édition la suite des opérations pour transformer x en y, on peut procéder comme pour la recherche du plus court chemin dans une grille. En explorant les distances des prédécesseurs on détermine un choix qui réalise une distance minimale vers un sommet. Ainsi on peut remonter le chemin du sommet (n, m) à (0,0), et produire le long du chemin la liste des opérations d’une solution optimale. Pour finir, il suffit d'inverser cette liste. 
3.2. DISTANCE D'ÉDITION DE LEVENSHTEIN 53  TS \% p pepéreepe e [ 1E @ 1\%\ 1\%\ 1\/â\ 1\7/3> = X  FIGURE 3.2 - Plus court chemin dans une grille orientée, déterminant la distance d’édition entre deux mots.  Détails d'implémentation Notez que dans la description du programme dyna- mique les indices du tableau commencent à 0, et ceux des chaînes à 1. À tenir compte dans une 1mplémentah0n \def levenshtein(x, y): - ‘ ; | | len(x) | m= len()  # initialisation ligne 0 et colonne 0 A = [[i + j for j in range(m + 1)] for i in range(n + 1)] for i in range(n): | ‘ for j in range(m): A[i + 1][j + 1] = minCA[i][j + 1] + 1, # insertion 1 A[i + 11[j] + 1, # suppress. … ! A[ilLj] + int(z[i] != y[j])) # substitut. ä return A[n][m]  Pourallerplusloin Desalgorithmes plus performants en pratique ont été proposés. Par exemple si une borne supérieure s est connue sur la distance d’édition, alors on peut restreindre le programme dynamique ci-dessus, aux entrées de À à distance au plus s de la diagonale, et obtenir une complexité O(s min{n, m}) [28]. 
54 CHAPITRE 3. SÉQUENCES  3.3 Plus longue sous-séquence commune  entrée: GAC, AGCAT sortie: AGCAT  L 1 G AC  Définition Soit E un ensemble de symboles. Pour deux séquences s, x € E*, on dit que s est sous-séquence de x s’il existe des indices i1 < … < ; tels que x;, = s; pour tout k = 1,.,|s|. Etant donné deux séquences x, y € E*, il faut trouver une séquence de longueur maximale s € L* qui est sous-séquence à la fois de x et de y. Une autre manière de voir le problème passe par les couplages, voir section 9.1 page 118. On cherche un couplage maximum entre lettres égales de x et de y tel que les liaisons entre les couples soient sans croisement, voir figure 3.3.  Application Dans un système de synchronisation de fichiers, pour minimiser le trafic réseau, au lieu de renvoyer vers le serveur tout fichier modifié en entier, on veut envoyer seulement les parties qui ont été modifiées. Pour cela il faut identifier la plus grande sous-séquence commune entre l’ancien et le nouveau fichier. Ce problème apparaît aussi en bio-informatique, lorsqu’il s'agit d’aligner deux chaînes de bases azotées.  Ficure 3.3 — Le problème de la plus longue sous-  ABCDAE séquence commune peut être vu comme un aligne- /‘ f ment maximal sans croisement entre les éléments î / / des deux séquences données. Par exemple un ali- A DE A gnement supplémentaire des lettres B croiserait l’ali-  gnement des lettres D.  Algorithme en O(nm) pour n = |x|,m = |y|. L'idée est que pour tout 0 < i <n et pour tout 0 < j < m on calcule la plus longue sous-séquence commune aux préfixes X1 ...X; et y1 … ÿ;. Ceci nous donne n-m sous-problèmes. Et la solution optimale pour (i, j) peut être calculée à partir des solutions pour (; — 1, j),(i j — 1) et (( —1,j — 1), en temps constant. On peut alors résoudre le problème pour (n, m) en temps O(nm). L'algorithme est basé sur l’observation suivante.  Observation clé Soit A[i, ‘] une plus longue sous-séquence commune à x; … x; et y -y;. Si i = 0 ou j = O alors Ali, j] est vide. Si x; # y; alors forcément une des lettres x;, x; n’est pas couplée dans une solution optimale et A[i, j] est le maximum entre A[i — 1, j] et A[i, j — 1]. Si x; = y; alors il existe une solution optimale qui met en correspondance ces lettres et A[i, j] est A[i — 1, j — 1] - x;. Ici le symbole « - » représente la concaténation, et par maximum on entend la séquence de longueur maximale. 
34. PLUS LONGUE SOUS-SÉQUENCE CROISSANTE 55  def longest_common_subsequence(x, y):  n = len(x) m = len(y) # -- calcul longueur optimale A = [[© for j in range(m + 1)] for i in range(n + 1)]  for i in range(n): for j in range(m): if x[i] == y[j]:  A[i + 11[j + 1]) = ACi][j] + 1 else: A[i + 11[j + 1] = max(A[i]J[j + 11, A[i + 11Cj) # -- extraire solution sol = []  Ï i, j=n,m | while A[i][j] > 0: if AFi][j] == A[i - 1][5j1:  à -1 elif A[il[j] == ACiJLj - 11: j-=1l else: i-=1 | j -=1 ! sol.append(x[i]) return ’’.join(sol[::-1]) # liste inversée  Variante avec plusieurs séquences données Supposons qu’on veuille calculer une plus longue sous-séquence commune non pas à 2 mais à k séquences données, de longueur respectivement ,, , #. Alors l’approche ci-dessus se généralise. Il faut calculer une matrice À de dimension k, calculant la plus longue séquence commune pour toute combinaison de préfixes des séquences données. La complexité de cet  algorithme est O(2* TTÉ, n;).  Variante avec deux séquences triées Le problème peut se résoudre en temps O(n + m) si les deux séquences sont triées, car dans ce cas on peut procéder comme pour la fusion de deux listes triées (voir section 4.1 page 59).  En pratique L'algorithme BLAST (pour Basic Local Alignment Search Tool) est utilisé, mais il ne garantit pas de produire toujours une solution optimale.  3.4 Plus longue sous-séquence croissante  Définition Étant donné une séquence de n entiers x, il faut trouver une sous- séquence de s de longueur maximale et strictement croissante.  Application Considérons une route droite, qui mène à la mer, et des maisons construites le long de la route. Chaque maison est constituée d’un certain nombre d'étages et bénéficie d'une vue sur la mer si toutes les maisons entre elle et la mer ont un nombre d’étages moindre. On aimerait que toutes les maisons aient vue sur la mer et détruire le plus petit nombre de maisons pour arriver à cette fin (voir figure 3.4). 
56 CHAPITRE 3. SÉQUENCES  h E E QQ E EN  0650900  Ficure 3.4 — Détruire le moins de maisons possibles afin que les maisons préservées aient vue sur la mer.  Algorithme en O(nlogn) Plus précisément, la complexité est O(nlogm) où m est la taille de la solution produite. Dans une approche gloutonne, on veut, pour chaque indice i, essayer de compléter avec x; une sous-séquence croissante du pré- fixe x;,..,x;-1. Mais laquelle de ces sous-séquences va aboutir à une solution opti- male ? Considérons l’ensemble des sous-séquences croissantes du préfixe. Dans une sous-séquence croissante y deux attributs sont importants : sa longueur, et le dernier élément. L'intuition : on aime bien que les sous-séquences croissantes soient longues — car c’est l’attribut à optimiser — et qui terminent avec un petit élément, puisque ceci permet de les compléter plus facilement. Pour formaliser cette intuition, notons par |y| la longueur d’une sous-séquence y et par y_; le dernier élément de y. On dit que y domine z si |y| > |z| et y < Z_ et qu'au moins une des inégalités est stricte. Il suffit de s’intéresser aux sous-séquences non dominées vont pouvoir être complétées en une sous-séquence optimale.  indice 0 1 2 3 4 b - 1 2 4 5  és à  x |3..1 4 15519220 6 55-451592- 0:57 5 9  FIGURE 3.5 — Calcul de la plus longue sous-séquence croissante. En gris l'entrée de x en cours de traitement. Pour le préfixe considéré les séquences maintenues sont (), (1), (1,2), (1,2, 4), (1,2, 4,5). Après le traitement de l’entrée 3, la séquence (1,2,4) sera détrônée par (1,2,3). 
3.4. PLUS LONGUE SOUS-SÉQUENCE CROISSANTE 57  Les sous-séquences non dominées du préfixe x1,...,i-1 diffèrent par leur lon- gueur. Donc pour chaque longueur k on garde une sous-séquence de longueur k terminant par un entier minimal. Concrètement, on maintient un tableau b tel que b[Kk] soit le dernier élément de la plus longue sous-séquence de longueur k. Par convention on note b[0] = —œ. L'observation clé est que le tableau b est strictement croissant. Ainsi, au moment du traitement de l’élément x;, au plus une seule des sous-séquences est à mettre à jour. Notamment si k est tel que b[k — 1] < x; < b[k], alors on peut améliorer la séquence de longueur k — 1 en la complétant par x;, et obtenir une meilleure sous-séquence de longueur k, dans le sens qu'elle termine avec un élément plus petit. Dans le cas où x; est plus grand que tous les éléments de b, on peut augmenter b avec l'élément x;. C’est la seule amélioration possible, et la recherche de l'indice k peut se faire par dichotomie en temps O(log |b]), où |b| est la taille de b.  Détails d'implémentation Les sous-séquences sont codées par des listes chaînées à l’aide de tableaux h et p. La tête de la liste est codée par H, tel que b[k] = x[h[k]]. Puis le prédécesseur d’un élément ; est p[j]. Les listes terminent par la constante None, voir figure 3.5.  | from bisect import bisect_left |  \ def longest_increasing_subsequence (x) :  | n = len(x) | p = [None] # n h = [None] b = [float('-inf’)] # - infinity for i in range(n): if x[i] > b[-1]: p[i] = h[-1] h.append(i) b.append(x[i]) else: # -- recherche dichotomique: b[k - 1] < x[i] <= b[k] k = bisect_left(b, x[il) h[k] =i b[k] = x[i] p[i] = h[k - 1] # extraire solution q = h[-1] s= [l  while q is not None: s.append(x[q1) q = plal return s[::-1]  Variante sous-séquence non décroissante Si la sous-séquence n’a pas besoin d'être strictement croissante, mais seulement non décroissante, alors au lieu de chercher k tel que b[k — 1] < x[i] < b[K], il convient de chercher k tel que b[k — 1] < x[i] < b[K]. Ceci se fait à l'aide de la fonction Python bisect_right. 
58 CHAPITRE 3. SÉQUENCES  Variante plus longue sous-séquence commune croissante Étant donné deux sé- quences x et y, on veut trouver la plus longue sous-séquence commune croissante. Ce problème peut être facilement résolu en temps cubique, en triant d'abord y, ce qui produit une séquence z, puis en cherchant une séquence commune à x,y,z. Un meilleur algorithme en O(|x| - [y|) a été publié en 2005 [29] mais cette complexité était pourtant déjà requise pour résoudre un problème posé pendant le concours ACM/ICPC/NEERC quelques années plus tôt, en 2003.  3.5 Stratégie gagnante dans un jeu à deux joueurs  Définition Considérons un jeu qui se joue à deux joueurs avec une pile d’entiers positifs, voir figure 3.6. Le joueur 0 commence. Si la pile est vide, il perd. Sinon, si le sommet de la pile contient un entier x, il a le choix entre dépiler une fois et dépiler x fois. La dernière option n’est autorisée que si la pile contient au moins x éléments. Puis c’est au joueur 1 de jouer, et ainsi de suite. À présent, on vous donne une pile P avec n entiers, et on vous demande si le joueur 0 possède une stratégie gagnante, c'est-à-dire s’il peut agir de façon à gagner quels que soient les choix du joueur 1.  °s V  FIGURE 3.6 — Une configuration du jeu.  Complexité linéaire.  Algorithme par programmation dynamique Simuler le jeu serait trop coûteux, les choix binaires entraîneraient une explosion combinatoire. C’est ici que la program- mation dynamique peut aider. Soit G un tableau de booléens de taille n, où G[i] indique s’il est possible pour un joueur de gagner s’il débute la partie avec la pile réduite aux i premiers éléments. L'idée est que le joueur 0 a une stratégie gagnante s’il arrive en un seul coup à mettre le joueur 1 dans une position où celui-ci ne peut plus gagner. On aa le cas de base G[0] = False, et le cas d’induction pour i > 0 est  -J Gli- Gli=1] v Gli-P[i]] si P[i] > 0 Cul '{ GE=1] sinon.  Par un simple parcours en un nombre linéaire d’étapes, il est possible de remplir le tableau G et de résoudre le problème en répondant G[n — 1]. 
4. Tableaux  Les tableaux figurent parmi les types de données élémentaires les plus impor- tants. Pour beaucoup de problèmes simples, aucune autre structure de données n’est requise. La modification d’une valeur à un indice particulier se fait en temps constant, mais contrairement aux listes il n’est pas possible d’insérer ou de supprimer des élé- ments, à moins de créer le tableau résultant en temps linéaire. Les éléments d’un tableau sont indexés à partir de 0. Il faut en tenir compte lors de la lecture et de l'affichage des données, si jamais la numérotation d’éléments commence à 1 dans les formats de lecture ou d’écriture. Un tableau peut aussi servir à coder de manière extrêmement simple un arbre binaire. Le père d'un sommet à l’indice / est [i/2], son fils gauche est à l’indice 2j et son fils droite à l’indice 2/ + 1. La racine est à l’indice 1 et l’élément à l’indice O est simplement ignoré. Cette section traite des problèmes classiques sur des tableaux, ainsi que des struc- tures de données pour effectuer des opérations sur des intervalles d’indices appelés plages, telles que le calcul de la valeur minimale au sein d’une plage. Les deux der- nières sections décrivent des structures de données dynamiques, qui proposent des opérations de modification de tableau et des opérations de requête.  Rappel : En Python, on peut accéder au dernier élément d’un tableau t par l’indice -1. Une copie inversée de t est obtenue par f[::—1]. Un nouvel élément est ajouté avec la méthode append. En ce sens, les tableaux de Python sont similaires à la classe ArrayList de Java ou la classe vector de C++.  41 Fusion de listes triées  Définition Étant donné deux listes triées x et y, on veut produire une liste z triée à son tour, contenant les éléments de x et de y.  Application Cette opération est utile dans l’algorithme de tri fusion. Pour trier un tableau, on le sépare en deux parties de taille égale (à un élément près si la longueur est impaire), et on trie chacune des parties de façon récursive. Puis on fusionne les deux parties triées avec la procédure décrite ci-dessous. Cet algorithme a une _ complexité optimale de O(n log n) comparaisons.  Algorithme en temps linéaire La solution consiste à parcourir les deux listes de manière conjointe et à construire z au fur et à mesure. La clé est d'avancer à chaque 
60 CHAPITRE 4 TABLEAUX  fois sur la liste qui comporte le plus petit élément. Ainsi on s'assure que le résultat soit ordonné à son tour.  \ de£f merge(x, y): | z = l i 0 j ô whiie i < len(x) or j < len(y): if j == len(y) or i < len(x) and x[i] < y[j]: z.append(x[i])  i+=1 else: z.appendCy[j]) j+=1l  return z  Variante avecklistes Pour trouver rapidement la liste dans laquelle on doit avancer, on peut stocker les k éléments courants de chacune des listes dans une file de priorité. La complexité est alors O(n log k) où n est la longueur de la liste produite.  4.2 Somme d’une plage  Définition Chaque requête est sous la forme d’un intervalle d'indices [i, ÿ) et il faut renvoyer la somme des éléments de t entre les indices i (inclus) et j (exclu).  Structure de données en O(1) par requête et initialisation en O(n) Il suffit de calculer un tableau s de taille n + 1, qui contient toutes les sommes des préfixes de t. Concrètement, s[j] = L; ; t[i]. En particulier s[0] = 0,s[1] = #[0], s[n] = Z,}Ïâ t[i]. Alors la réponse à la requête [i, j) est s[j] — s[i].  4.3 Doublon d’une plage  Définition Chaque requête est sous la forme d’un intervalle d'indices [i, ÿ) et il faut trouver un élément x qui apparaît au moins deux fois dans # dans cet intervalle, ou annoncer que tous les éléments sont distincts.  Structure de données en O(1) par requête et initialisation en O(n) Par un balayage de gauche à droite, on construit un tableau p qui indique pour chaque / le plus grand indice < jtel quet[i] = t[/]. Quand f[j] est vu pour la première fois, on pose plil = —1. Le calcul de p nécessite de stocker pour chaque x l’indice de la dernière occurrence observée de x dans t au cours du balayage. Ceci peut être fait par un tableau si on a une garantie sur le domaine des éléments de f ou sinon par un dictionnaire basé sur une table de hachage. Puis il faut stocker parallèlement dans un tableau q, pour chaque / la plus grande valeur p[i] sur tout ; < j. Ainsi pour répondre à la requête [i, j), si jamais q[j — 1] < i alors tous les éléments dans [i, ÿ) sont distincts, sinon t[g[j—1]] est un élément double. Pour déterminer les indices des deux occurrences, il suffit de calculer en parallèle avec q[j] l’indice i qui a réalisé le maximum. 
4.4. PLUS GRANDE SOMME D'UNE PLAGE 61  44 Plus grande somme d'une plage  Définition Ce problème statique demande pour un tableau de valeurs t, le maxi- mum sur é[i] + t[i + 1] + … + t[j] pour tout couple d'indices i, ÿ avec i < 7.  Algorithme en O(n) Cette solution par programmation dynamique a été trouvée par Jay Kadane en 1984. Pour chaque indice j on cherche la somme maximale f[i] + … + f[j] sur tous les indices 0 < / < j. Notons A[j] cette valeur. Cette valeur est soit seulement f[/] soit elle se compose de f[j] et d'une somme de la forme t[i]+...+#[j-1] qui a son tour doit être maximale. Donc A[0] = #[0] car c’est la seule option et pour j > 1, nous avons la récurrence A[j] = t[j] + max{A[j — 11,0}.  Variantes Ce problème se généralise aux matrices. Étant donné une matrice M de dimension n x m, un intervalle d’indices de lignes [a, b] et un intervalle d’indices de colonnes [i, /] définit un rectangle [a, b] ® [i, j] dans la matrice. On cherche le rectangle de plus grande somme. Pour cela il suffit de boucler sur tous les intervalles d’indices de lignes [a, b] et de produire un tableau t de longueur m, tel que f[i] = M{[a, i] + … + M[b, i]. Ce tableau peut être obtenu en temps O(m) à partir du tableau correspondant aux lignes [a, b — 1], en ajoutant simplement la b-ième ligne de M. À l'aide de l’algorithme ci-dessus, on peut trouver en temps O(m) un intervalle de colonnes [i, j] qui décrit un rectangle de somme maximale décrit par les intervalles [a, b] et [i, j]. Cette approche produit une solution de complexité O(n?m).  4,5 Requêtes de minimum d’une plage — arbre de segments  Définition On veut maintenir une structure de données qui stocke un tableau t de n valeurs permettant les opérations suivantes. — changer t[;] pour un indice i donné. — calculer min;<;xt[j] pour une plage d'indices i, k donnée.  Variante Sans grand changement on peut aussi déterminer l’indice de l’élément minimum plutôt que sa valeur.  Structure de données en O(log n) par requête L'idée est de compléter le tableau t avec un arbre binaire, appelé arbre de segments. Chaque nœud est responsable d'une plage d'indices dans t, voir figure 4.1. La taille d’une plage est une puissance de 2, et les deux fils du nœud sont responsables des deux moitiés de la plage. Les derniers niveaux de l’arbre contiennent les valeurs de t. Dans chaque nœud, on va simplement stocker le minimum des valeurs du tableau dans la plage concernée. Une mise à jour du tableau nécessite un nombre logarithmique de mises à jour dans la structure, sur chaque nœud le long du chemin vers la cellule concernée. La recherche du minimum dans une plage [i, k) donnée se fait par une exploration récursive de l’arbre. La fonction _range_min(j, start, span, i,k) renvoie le minimum du tableau t dans les indices de [start, start+span) N [i, k), où / est l’indice du nœud correspondant à cette plage. La recherche termine soit quand la plage du nœud 
62 CHAPITRE 4. TABLEAUX  courant est incluse dans [i,k) — auquel cas on doit renvoyer la valeur du nœud — soit quand la plage du nœud courant est disjointe de [i, k) — auquel cas on doit renvoyer +c.  1 8 3 2 5 00 c. 3/1/9/8/3/4/2|/7/|5|%0|0| 0|x0|œ  8  8  Froure 4.1 — L'arbre utilisé dans la structure pour déterminer le minimum dans une plage.  Analyse de complexité Pour se convaincre que le temps passé par requête est O(log n), nous allons distinguer 4 types de nœuds traversés par le parcours. Soit [s, t) la plage correspondant à un nœud. — Le nœud est dit vide si [s, t) est disjoint de [i, K). — Le nœud est dit plein si [s, t) € [i, K). — Le nœud est dit strict si [s, t) D [i, k). — Sinon le nœud est dit chevauchant. Notez que pour un nœud chevauchant, les intervalles [s, #) et [i, K) s'intersectent, sans être inclus l’un dans l’autre. L’analyse découle d’une borne sur le nombre de nœuds de chaque type. La recherche range_min traverse un nombre logarithmique de nœuds pleins, qui correspondent à la décomposition de [i, k) en plages disjointes. Il en est de même avec les nœuds vides qui correspondent à une décomposition du complément de [i, k) en plages. L'analyse termine par l’observation qu’un nœud chevauchant a toujours un fils soit plein, soit vide, et que seule la racine peut être un nœud strict. 
4.5. REQUÊTES DE MINIMUM D'UNE PLAGE - ARBRE DE SEGMENTS 63  \class RangeMinQuery:  | | ! ! |  def  def  def  def  def  __init__(self, t, INF=float('inf’)): self.INF = INF  self.N =1 while self.N < lenCt): # trouver la taille N self.N * 2 self.s = [self.INF] #* (2 * self.N) for i in range(len(t)): # poser t aux feuilles  self.s[self.N + i] = t[i] for p in range(self.N - 1, 6, -1): self.s[p] = min(self.s[2 * p], self.s[2 * p + 1])  __getitem__(self, i): return self.s[self.N + i]  __setitem__(self, i, v): p=self.N+i self.s[p] = v  # remplir les noeuds  p //= 2 # remonter dans l'arbre  while p > 0: # mettre à jour le noeud £  self.s[p] = min(self.s[2 * p], self.s[2 * p + 1]) p //= 2  range_min(self, i, k): return self._range_min(1, O, self.N, i, k)  _range_min(self, p, start, span, i, k):  if start + span <= i or k <= start: # intervalles return self.INF if i <= start and start + span <= k: # intervalles return self.s[p] left = self._range_min(2*p, start, span  right = self._range_min(2*p + 1, start + span // 2, span return min(left, right)  disjoints inclus  // 2, i, D  // 2, i, K 
64 CHAPITRE 4. TABLEAUX  4.6 Requêtes de somme d’une plage — arbre de Fenwick  Définition On veut maintenir une structure de données qui stocke un tableau t de n valeurs et permet les opérations suivantes. — mettre à jour f[;] pour un indice / donné. — calculer t[1] + … + t[i] pour un indice ; donné. Pour des raisons techniques les indices de t varient de 1 à n — 1, sans inclure 0.  Variante Avec une petite modification, cette structure peut également servir à ef- fectuer les opérations suivantes en temps O(log n). — ajouter une valeur à tout un intervalle f[a], t[a +1], … , t[b] pour des indices a, b donnés. — demander la valeur de f[;] pour un indice / donné. Ce problème peut être résolu avec un arbre de segments, comme décrit dans la section précédente. En y remplaçant la constante c par 0 et l’opération min par l’ad- dition on obtient une structure qui résout ce problème avec une complexité O(log n) par requête. La structure que nous décrivons dans cette section a des performances similaires, en revanche elle est plus rapide à implémenter.  Structure en O(log H) par requête Voir [6]. L'idée de la structure est stocker non le tableau t tel quel mais seulement des sommes d'’intervalles de t. Ainsi on crée un nouveau tableau s tel que s[/] est la somme de f[j] sur ; € I(i), où I(i) est un intervalle défini ci-dessous. Ces intervalles sont organisés sous forme d’arbre par inclusion de la manière suivante, avec deux relations : père et voisin gauche (voir figure 4.2). — L'entrée s[i] où ; est de la forme a10* en binaire pour a € {0,1}* contient la somme de t dans l’intervalle I(i) = (a0*1,..… , }. — Le père de l’indice / = a10* est ; + 10*. — Le voisin gauche de l’indice / = a10* est j = a00*. L'intervalle I(j) est celui qui touche à gauche I(:). Ainsi la somme du préfixe t[1] + … + #[i] avec i = a10* est s[i] plus la somme du préfixe t[1] + … + t[a00*], de façon récursive. Par exemple dans la figure 4.2, la mise à jour de t[11] doit changer s[11 = 01011z], s[12 = 01100,], s[16 = 10000,] et le préfixe #[1] … t[11] a la somme de s[11 = 01011], s[10 = 01010,], s[8 = 01000,]. Une opération importante pour implémenter cette structure consiste à lire le bit de poids faible de ;, c’est à-dire transformer un nombre en notation binaire a10* en 10*. Cette opération est tout simplement i&-i. Explication :  a10* 01* —i=i+1=a0 i& — i = 10*  i i  Il = 
4.6. REQUÊTES DE SOMME D'UNE PLAGE - ARBRE DE FENWICK 65  16  binaire père voisin 001 0 010 011 100 101 110 111  l 3 5 7 9 11 13 15  JO 015 OD NH. % % Ox D #H H IN OH A> 85SNWNS  = t |0/1/2{/3/|4]5|6/7|8/9|10/11|12/13/14{15{16  FIGURE 4.2 — Exemple d’un arbre Fenwick. La relation « est père de » se lit verticalement du haut vers le bas, par exemple 8 est le père de 4, 6 et 7. Un indice ; est voisin gauche de ; si l’intervalle I(?) qui touche I(j) à gauche, par exemple 4 est le voisin gauche de 5 et 6.  \class Fenwick: def __init__(self, t): | self.s = [0] * lenCt) | for i in range(1, len(t)): self.add(i, t[i])  def prefixSum(self, i): sum = 0 while i > 0: sum += self.s[i] i -= (i & --i) return sum  def intervalSum(self, a, b): return self.prefixSum(b) - self.prefixSum(a-1)  | def add(self, i, val): assert i > 0 while i < len(self.s): self.s[i] += val i + Ci &-i)  # variante:  def intervalAdd(self, a, b, val): self.add(a, +val) self.add(b + 1, -val)  | def get(self, i): | return self.prefixSum(i) 
66 CHAPITRE 4. TABLEAUX  4,7 Fenêtres avec k éléments distincts  Définition Étant donné une séquence x de n éléments et un entier k, on souhaite déterminer tous les intervalles maximaux pour l’inclusion [i, j) tels que x;,..., xj soit composé d’exactement k éléments distincts, voir figure 4.3.  ! 21 F 2 FIGURE 4.3 — Une fenêtre contenant exactement À ' 2 éléments distincts. i j  Application Un cache est une mémoire rapide qui est placée devant une mémoire à accès lent. L'espace d’adressage de la mémoire lente est partitionné en blocs de même taille, appelés pages. Le cache est de capacité limitée et peut contenir k pages. Les accès mémoire de la machine au cours du temps forment une séquence x dont chaque élément est une page demandée. Quand une page demandée est dans le cache, l’accès est rapide, sinon on parle d’une faute de cache et il faut chercher la page de la mémoire lente et la placer dans le cache à la place d’une autre page qui sort du cache. Chercher des intervalles dans x contenant exactement k éléments distincts revient à trouver les intervalles de temps pendant lesquels aucune faute de cache n’arrive, sous l’hypothèse d'une configuration de cache initiale favorable.  Algorithme en O(n) L'idée est de parcourir la séquence x avec deux curseurs i et j qui définissent une fenêtre. On maintient le nombre d’éléments distincts de l’ensemble x;,,x;-1 dans une variable dist, à l’aide d’un compteur d'occurrences occ pour chaque élément. Lorsque dist dépasse le paramètre k, on avance i, sinon on avance /. L'implémentation suivante renvoie un itérateur, et peut être utilisée par une autre fonction pour traiter chaque intervalle de façon individuelle. Comme chacun des deux curseurs / et j ne fait qu'avancer, on effectue au plus 2n opérations, d’où une complexité linéaire.  \ def windows_k_distinct(x, k):  ] Î ! dist, i, j = 6, 60, 0 # dist = |{x[i],..,x[j-1]}| E | occ = {xi: O for xi in x} # nombre d'occurrences en x[i:j] | while j < len(x): | â while dist == k: # déplacer début intervalle é | occ[x[i]] -= 1 # mettre à jour compteurs if occ[x(i]] == 0: | dist -—= 1 Ë i + l | while j < len(x) and (dist < k or oce[x[j11): if occ[x[j]] == 6: # mettre à jour compteurs dist += 1 | oce[x[j]] += 1 | j+=1 # déplacer fin intervalle if dist == k: 3 yield ÇCi, j) # un intervalle détecté  E p t crrr rr rrn 
; VV 5. Ifltervalles  Plusieurs problèmes qui portent sur des intervalles peuvent se résoudre par pro- grammation dynamique. L'ensemble des intervalles qui sont avant ou après un seuil donné peuvent former deux sous-instances indépendantes. Si le problème le permet, il est agréable d'utiliser des intervalles semi-ouverts de la forme [s, t), car le nombre d’entiers qu'ils contiennent est alors facile à calculer (ici simplement t — s, pourvu que s, t soient entiers).  5.1 Arbre d’intervalles  Définition Le problème consiste à stocker n intervalles donnés dans une structure afin de pouvoir répondre rapidement à des requêtes de la forme suivante : pour une valeur donnée p, quelle est la liste de tous les intervalles contenant p ? Nous supposons que tous les intervalles sont de la forme semi-ouverte [J, h), mais la structure s’adapte à d'autres formes d'intervalles.  Structure de données en O(log 1 + m) parrequête où m est le nombre d'intervalles renvoyés. La structure est un arbre binaire construit comme suit. Soit S un ensemble d'intervalles à stocker. On choisit une valeur center comme décrit plus bas. Cette valeur divise les intervalles en trois groupes. L'ensemble L des intervalles à gauche de center, l’ensemble C des intervalles contenant center etl’ensemble R des intervalles à droite de center. Alors la racine de l’arbre stocke center et C et de façon récursive, les sous-arbres gauche et droit stockent L et R, voir figure 5.1. Pour pouvoir répondre rapidement aux requêtes, l’'ensemble C est stocké sous forme de listes triées. La liste by_low stocke les intervalles de C ordonnés par leur début, tandis que la liste by_high stocke les intervalles de C ordonnés par leur fin. Pour répondre à une requête pour un point p, il suffit de comparer p avec center. Si p < center, alors il faut de façon récursive chercher les intervalles contenant p dans le sous-arbre gauche et y ajouter les intervalles [J, h) de C avec ! < p. Ceci est correct, car par construction ces intervalles satisfont h > center et donc p € [I,h). Sinon, si p > center, alors il faut de façon récursive chercher les intervalles contenant p dans le sous-arbre droit et y ajouter les intervalles [/, h) de Cavecp <h.  Choix de center Pour que l’arbre binaire soit équilibré, on peut choisir center comme la valeur médiane des débuts des intervalles à stocker. Ainsi la moitié des intervalles seront stockés dans le sous-arbre droit, ce qui garantit une profondeur logarithmique. De manière similaire au comportement de l’algorithme de tri quick- 
68 CHAPITRE 5. INTERVALLES  Ficure 5.1 — Un arbre stockant 7 intervalles.  sort, si le centre est choisi au hasard parmi les débuts d'intervalle, les performances seront similaires en espérance.  Complexité La construction de l’arbre se fait en temps O(nlog n), et le traitement d’une requête coûte O(log n + m), où la partie logarithmique est due à la recherche dichotomique dans les listes triées.  Détails d’implémentation Dans notre implémentation, les intervalles sont repré- sentés par des n-uplets dont les deux premiers éléments stockent le début et la fin de l’intervalle. D’autres éléments peuvent être présents pour transporter des informa- tions supplémentaires. La recherche dichotomique est faite via la fonction bisect_right(t,x) qui renvoie i tel que ?[j] > x ssi j > i. Notez qu'il ne faut pas boucler dans le tableau by_high à l’aide de la sélection de sous-liste by_high/i :], car la création de la sous-liste est linéaire en len(by_high), ce qui augmenterait la complexité en O(log n + n) au lieu de O(log n + m), où m est la taille de la liste renvoyée. Les tableaux contiennent des couples (valeur, intervalle), alors nous recherchons avec bisect_right le point d’insertion d’un élément x de la forme (p, (c, œ)). 
5.2. UNION D'INTERVALLES  Î  }class _Node: | | |  def __init__(self, center, by_low, by_high, left, right):  self.center center self.by_low by_low self.by_high = by_high self.left = left self.right = right  69  smt S  \ def interval_tree(intervals): | # le test suivant dégraderait les performances | # assert intervals == sorted(intervals) | if intervals == [J: | return None É center = intervals[len(intervals) // 21[0] | L = [l ; R= 0 c=l | for I in intervals: | if I[1] <= center: ! L.append(I) ä elif center < I[0]: Ï R.append(I) | else: | % C.append(I) | by_low = sorted((I[6], I) for I in C) | i by_high = sorted((I[1], I) for I in C) | IL = interval_tree(L) | IR = interval_tree(R) return _Node(center, by_low, by_high, IL, IR) | | def intervals_containing(t, p): | | INF = floatC'inf’) | | if t is None: î return [] | | if p < t.center: | | retval = intervals_containing(t.left, p) | j = bisect_right(t.by_low, (p, (INF, INF))) | for i in rangeCj): | retval.append(t.by_low[i][1]) i else: | retval = intervals_containing(t.right, p) | i = bisect_right(t.by_high, (p, (INF, INF))) 1 for j in range(i, len(t.by_high)): 1 retval .append(t.by_high[j1[11) î return retval 5.2 Union d’intervalles  Définition Étant donné un ensemble S de n intervalles, on veut déterminer leur union, sous la forme d’une liste ordonnée L d’intervalles disjoints, avec la propriété  Ules = UIeL- 
70 CHAPITRE 5. INTERVALLES  Algorithme en O(nlogn) par balayage. On balaye les extrémités des intervalles de gauche à droite. À tout moment donnée on maintient dans open le nombre d'intervalles ouverts (dont on n’a pas encore vu la fin). Quand ce nombre devient Zéro, on doit ajouter à la solution un nouvel intervalle [open,x), où x est la position courante du balai et open la dernière position à laquelle open est devenue positif.  Détails d'implémentation Notez l’ordre dans lequel les extrémités des intervalles sont traitées. Cet ordre est correct quand les intervalles sont fermés ou semi-ouverts. Pour des intervalles ouverts, il faudrait traiter la fin de l’intervalle (x, y) avant le début de l’intervalle (y, z).  def intervals_union(S): E = [(low, -1) for (low, high) in S] E += [Chigh, +1) for (low, high) in S] nb_open = 06 last = None retval = [] for x, _dir in sorted(E):  if _dir == -1: if nb_open == 0: last = x nb_open += 1 else: nb_open -= 1  if nb_open == 0: retval.append((last, x)) return retval  5.3 Couverture d’intervalles  Application Soit une plage toute droite et des îles, aussi petites que des points. On veut placer un nombre minimum d’antennes le long de la plage pour couvrir chaque île. Une antenne peut couvrir toutes les îles dans un rayon r donné, qui est le même pour toutes les antennes.  Observation Quand on trace un cercle autour de chaque île de rayon r, on découvre un intervalle sur la plage qui doit contenir une antenne. Le problème se réduit alors au problème suivant, voir figure 5.2.  Définition Pour n intervalles donnés, il faut trouver un ensemble S minimum tel que chaque intervalle intersecte S.  Algorithme en O(nlogn) par balayage. On traite tous les intervalles dans l’ordre croissant de leur côté droit. À tout moment on maintient une solution S pour les intervalles déjà vus, qui minimise |S| et en cas d’égalité maximise max S. L'algorithme est simple. Si pour un intervalle [/,r] on a ! < marxS, alors on ne fait rien, sinon on ajoute r à S. L'idée est qu’il faut de toute façon couvrir [I, r] et en choisissant la valeur la plus grande qui le couvre, on augmente la chance de couvrir également les intervalles traités par la suite. 
5.3. COUVERTURE D'INTERVALLES 71  terre  w® 0  FIGURE 5.2 — Combien faut-il d’antennes pour couvrir toutes les îles ? Ce problème se réduit au problème de couverture d’intervalles.  %def interval_cover(I): ] | s= l î Î for start, end in sorted(I, key=lambda v: (v[1], v[6])): ! Ï if not S or S[-1] < start: ! S.append(end) | | return S - | 
- 6. Graphes  Un graphe est un objet combinatoire composé d’un ensemble de sommets V et d'un ensemble d’arêtes E. Les arêtes correspondent à des couples de sommets généralement distincts, et sans notion d’ordre dans le sens où (u, v) et (v, u) désignent la même arête. Parfois on considère une variante, les graphes orientés, où les arêtes ont une orienta- tion. Dans ce cas elles sont habituellement appelées arcs. L'arc (u, v) a comme origine u et destination v. La plupart des algorithmes exposés dans cet ouvrage opèrent sur des graphes orientés, mais peuvent être appliqués à des graphes non orientés en remplaçant les arêtes (u, v) par deux arcs (u, v) et (v, u). Les graphes peuvent contenir des informations supplémentaires, telles que des poids ou des lettres, sous forme d’étiquettes sur les sommets ou sur les arêtes.  6.1 Codage en Python  Une manière simple est d’identifier n sommets par les entiers de 0 à n — 1. En revanche, il est courant de numéroter les sommets à partir de 1 lors d’un affichage, ou d’un codage dans un fichier d’entrée. Pensez alors à ajouter ou enlever 1 des indices au moment de la lecture et de l’affichage.  :;Vliste d'adjacence q G = [[1,2],[0,2,3],[0,1],[1]]  Ë# matrice d'adjacence ‘ll’ "Ë’ c= [E0,1,1,03,  [1,0,1,1], [1,1,0,0], [6,1,6,6]]  Frcure 6.1 — Un graphe et ses codages possibles.  Les arêtes peuvent être représentées essentiellement de deux manières, par liste d’adjacence ou par matrice d’adjacence, parfois plus simple à mettre en œuvre mais plus gourmande en espace. Dans ce dernier cas, le graphe sera représenté par une matrice binaire E telle que F[u, v] indique l’existence de l’arc (u, v). Voir figure 6.1. 
74 CHAPITRE 6. GRAPHES  La représentation par liste d’adjacence consiste à symboliser le graphe par une liste de listes G. Pour un sommet u, G[u] sera la liste des voisins de u. On peut aussi désigner les sommets par identifiant textuel, ainsi G peut être un dictionnaire dont les clés sont des chaînes de caractères et les valeurs les listes des voisins. Par exemple le triangle composé des trois sommets ‘axel”, ‘bill’, ’carl’ sera codé par le dictionnaire suivant.  {'axel':['bill’,’carl'], ’bill’:[’axel’,’carl’], ’carl’:[’axel’,’bill’]} Les algorithmes présentés dans ce livre travaillent généralement avec une liste d'adjacence. Dans un graphe orienté, parfois on a besoin d'avoir deux structures G_out, G_in, contenant les arcs sortants et les arcs entrants pour chaque sommet. Mais plutôt que de stocker l’arc, on stocke les extrémités des arcs. Donc pour tout sommet u, G_out[u] contient la liste des sommets v pour chaque arc sortant (u, v) et G_in[u] la liste des sommets v pour chaque arc entrant (v, U). Une manière simple pour stocker les étiquettes sur les sommets et les arêtes est d’utiliser des tableaux complémentaires indexés par les sommets ou matrices indexées par des couples de sommets. Ainsi la structure du codage G du graphe lui-même n'est pas affectée, et G peut être utilisé sans modification dans des parties du code, ignorant les étiquettes. L'écriture de classes pour représenter les sommets et arêtes avec leurs attributs est trop coûteuse pour coder rapidement.  6.2 Codage en C++ ou Java  Comme les listes des bibliothèques standards sont un peu plus fastidieuses à manipuler et un peu lentes en C++ ou Java, nous proposons une représentation très efficace, sous forme de liste chaînée. Chaque arc est identifié par un indice e tel que dest[e] est la destination de l’arc e. Les arcs sortants d'un sommet u sont organisés en liste chaînée de la manière suivante. Le premier arc de cette liste est arc[u], le deuxième succ[arc[u]], le troisième succ[succ[arc[u]]] et ainsi de suite, la liste terminant par la valeur spéciale —1. Pour un sommet u sans arc sortant on aura arc[u] == -1. Il est alors facile d’insérer un nouvel arc dans cette structure et de boucler sur tous les arcs sortants d’un sommet. On pourra définir une macro de préprocesseur C++ forall_neighbors pour faciliter l’écriture de cette boucle. Le test e !=-1 && (v=dest[e], 1) a l’effet suivant. Le et logique est paresseux. Donc si e contient le marqueur -1, alors le test échoue et la boucle termine. Sinon la deuxième expression est évaluée. Elle se compose de deux expressions séparées par une virgule. La valeur est toujours la valeur de la deuxième expression, donc 1, ce qui force l’itération. La première expression a pour effet d’initialiser v à l’extrémité de l'arc e, à chaque itération dans la boucle. Pour ajouter des poids aux arcs, ou tout autre attribut, il suffit de déclarer un nouveau tableau, indexé par les arcs.  500; // par exemple 2*MAX_NODES*MAX_NODES; // par exemple  const int MAX_NODES const int MAX_ARCS 
6.3. GRAPHES IMPLICITES ‘ 75  int nb_nodes 0; int nbLarcs = 0; int arc[MAX_NODES] = {0}; int succ[MAX_ARCS], dest [MAX_ARCS];  void clear_graph(int n) { nb_nodes = n;  nb-arcs = 0; for (int v=0; v<nb_nodes; v++) arc[v] = -1;  }  void add_arc(int u, int v) { succ[nb_arcs] = arc[u]; dest [nb_arcs] = v; arc[u] = nb_arcs++;  }  #define forall_neighbors(u, v) \ for (int e=arc[u]; e!=-1 && (v=dest[e], 1); e=succ[e])  6.3 Graphes implicites  Parfois le graphe est donné de manière implicite, par exemple sous forme d’une grille, où les sommets seraient les cellules de la grille, et les arêtes correspondent à des cellules adjacentes, comme dans un labyrinthe. Un autre exemple de graphe donné implicitement est celui d’objets combinatoires, où un arc correspond à une modification locale.  Exemple : Rusu Hour  FIGURE 6.2 Une configuration du jeu Rusx Hour.  Rusx Hour est un jeu disponible en commerce. Le plateau de jeu est une grille de dimension 6x6, voir figure 6.2. Des voitures (de longueur 2) et camions (de longueur 3) sont disposés sur cette grille, sans chevauchement et sans dépasser le bord de la grille. Une voiture particulière est marquée en rouge, et le but est de la faire sortir de  la grille par l’unique ouverture sur le bord. Pour cela il est possible de déplacer les voitures en les faisant avancer ou reculer. 
76 CHAPITRE 6. GRAPHES  La modélisation en graphe est immédiate. Chacune des k voitures a une partie fixe et une partie variable. La partie fixe est composée de la taille, l’orientation, et la coordonnée fixe (par exemple la colonne pour une voiture orientée verticalement). La partie variable est constituée de la coordonnée libre. Le vecteur de tous ces coor- données code complètement une configuration de la grille. La clé d’une exploration de ce graphe est une fonction, qui à partir d'un vecteur de configuration énumère tous les vecteurs de configuration atteignables en un mouvement. Le codage de la configurahon donnée en figure 6.2 est le suivant.  0] # 0=horizontal, 1= Vertical  orientat = [l e, 1, 6, 6, 1, 1, 6, 0, 1, longueur = [2, 3, 3, 3, 2, 2, 2, 2, 3, 2, 2] # 2=voiture, 3=camion coorfixe = [6, 6, 4, 1, 2, 2, 3, 3, 4, 5, 5] \coorvari = [6, 1, 6, 1, 06, 2, 2, 4, 2, 4, 3] rouge = 4 # indice voiture rouge  Par exemple, si orientat[#] = O alors la voiture ; occupe toutes les cases (x, y) pour coorvari[i] < x < coorvari[i] + longueur[i] et y = coorfixe[i]. Si orientat[i] = 1 alors la voiture / occupe toutes les cases (x, y) pour x = coorfixe[i] et coorvari[i] < y < coorvari[i] + longueur[?].  6.4 Parcours en profondeur —- DFS  Définition Le parcours en profondeur est une exploration de graphe qui débute en un sommet donné et explore ses voisins de façon récursive. Le nom anglais est DFS pour depth-first search.  Complexité La complexité en temps est O(|V| + |F]).  Application L'application principale est de découvrir les nœuds accessibles depuis un sommet d’un graphe. Mais ce parcours est également la base de plusieurs algo- rithmes que nous allons aborder par la suite, tels que la détection de composantes bi-connexes ou le tri topologique, voir sections 6.7 page 81 et 6.8 page 85.  Détail d'implémentation Pour ne pas explorer de manière répétée les voisins d’un sommet, on marque les sommets déjà v151tés, à l’aide d’un tableau de booléens  def dfs_ recur51ve(graph. node. seen) seen[node] = True for neighbor in graph[node]: if not seen[neighbor]: dfs_recursive(graph, neighbor, seen)  Implémentation améliorée L'implémentation récursive ci-dessus ne peut pas trai- ter de grands graphes, car la pile des appels est limitée. En Python, la fonction setrecursionlimit permet de dépasser un peu cette limite, mais généralement pas au-delà d’une profondeur de milliers d'appels récursifs. Pour pallier ce problème et pour plus d’efficacité, une implémentation itérative est proposée. La pile to_visit contient les sommets découverts mais pas encore traités. 
6.5. PARCOURS EN LARGEUR — BFS 77  def dfs 1terat1ve(graph, start. seen) : seen[start] = True | to_rvisit = [start] while to_visit: node = to_visit.pop() for neighbor in graph[node]: if not seen[neighbor]: seen[neighbor] = True to_visit.append(neighbor)  Le cas d’une grille Considérons une grille dont certaines cases sont franchissables (symbolisées par le caractère .) et certaines cases ne le sont pas (symbolisées par #), à la manière d'un labyrinthe. À partir d’une case, on peut atteindre les 4 cases voisines adjacentes, excepté pour les cases du bord, qui ont moins de voisins. Dans l'implémentation qui suit, on se sert de la grille pour marquer avec une lettre X les cases visitées. Pour simplifier la lisibilité, le parcours récursif est présente  def dfs grid(grid, i, j, mark ’X’ free='.'): height = len(grid) width = len(grid[6]) to_rvisit = [Ci, j)7 | grid[i][j] = mark Ï while to_visit: | il, j1 = to_visit.pop() for i2, j2 in [Cil + 1, j1), Cil, j1 + 1), (iil - 1, j1), Cil, j1 - 191: if 0 <= i2 < height and 0 <= j2 < width and grid[i2][j2] == free. grid[i2][j2] = mark # marquer visite | to_visit.append((i2, j2))  6.5 Parcours en largeur — BFS  Définition Plutôt que d’explorer le plus loin possible à partir du nœud courant (parcours en profondeur), on peut énumérer les nœuds du graphe par distance crois- sante au nœud initial (parcours en largeur). Le nom anglais est BFS pour breadth-first search.  Observation clé Il faut traiter les nœuds par distance croissante au nœud initial, ainsi il nous faut une structure permettant de maintenir cette ordre. La file est un bon choix : si pour chaque sommet extrait de tête on ajoute ses voisins à la queue, on peut montrer qu’à tout moment elle ne contient que des nœuds à distance d en tête et d + 1 en queue, et tant que les sommets à distance d ne seront pas épuisés en tête, on n’aura que des sommets à distance d + 1 ajoutés en queue.  Algorithme en temps linéaire O(|V|+|E]) Le parcours en largeur a la même structure que le parcours en profondeur itératif, à la différence qu’une file est utilisée à la place de la pile. Une autre différence est que les sommets sont marqués au moment où ils 
78 CHAPITRE 6. GRAPHES  entrent dans la file et non au moment où ils sortent de la file, sinon la mémoire atteint une complexité quadratique.  Détail d'implémentation L'intérêt principal du parcours en largeur est de déter- miner les distances à partir d’une source donnée dans un graphe non pondéré. Notre implémentation calcule ces distances ainsi que les prédécesseurs dans l’arbre des plus courts chemins. Le tableau des distances sert alors en même temps de marquage pour les sommets rencontrés.  \ from collections import deque  def bfs(graph, start=6): to_visit = deque() dist = [floatC'inf')] * len(graph) prec = [None] * len(graph) | dist[start] = 6 to_visit.appendleft(start) while to_visit: # une file vide évalue à Faux | node = to_visit.pop() for neighbor in graph{[node]: if dist[neighbor] == floatC(’inf’): dist[neighbor] = dist[node] + 1 prec[neighbor] = node to_visit.appendleft(neighbor) return dist, prec |  6.6 Composantes connexes  Définition Une partie A C V d'un graphe est appelée composante connexe si pour tous sommets u, v de À, il existe un chemin de u à v. On peut par exemple s’intéresser au nombre de composantes connexes d’un graphe. Un graphe est dit connexe lorsqu'il a une unique composante connexe. La figure 6.3 montre le logo du groupe Clean Bandit en ASCII art. Il peut être vu comme un graphe dont les sommets sont les dièses et deux sommets sont reliés par une arête si et seulement s’ils se touchent, horizontalement ou verticalement. Ce graphe comporte 4 composantes connexes.  Application Considérons une photo prise à la verticale d’un dé sur une table. On veut déterminer facilement le nombre de points sur la face exposée. Pour cela on postérise ! l’image avec un seuil de niveau de gris pour obtenir une image en noir et blanc, de façon que chaque point du dé corresponde à une composante connexe dans l'image, voir figure 6.4.  Algorithme par un parcours en profondeur Un parcours en profondeur partant d’un sommet u explore tous les sommets atteignables depuis u et seulement eux,  1. C’est-à-dire qu’on en réduit le nombre de couleurs. 
6.6 COMPOSANTES CONNEXES | 79  donc exactement la composante connexe contenant 4. On utilise le numéro de la composante courante comme marqueur de visite.  def dfs_grid(grid, i, j, mark, free): î grid[il[j] = mark | height = len(grid) width = len(grid[6]) for ni, nj in [Ci + 1, j), Ci, j + D, # 4-neighborhood i G - 1, j), Ci, j - 1917: | if 0 <= ni < height and 0 <= nj < width: 1 if grid[ni][nj] == free: dfs_grid(grid, ni, nj, mark, free)  On effectue autant de parcours en profondeur qu'il y a de composantes connexes. On balaie la grille en hauteur et largeur. Dès qu’une case contenant un dièse est rencontrée, on sait qu‘on a affaire à une nouvelle composante connexe et un parcours en profondeur est lancé à partir de cette case pour la déterminer entièrement.  def nb_connected_components_grid(grid, free='#'): nb_components = 0 height = len(grid) width = len(grid[6]) for i in range(height): for j in range(width): | if grid[i][j] == free: nb_components += 1 dfs_grid(grid, i, j, str(nb_components), free) return nb_components  Chaque case contenant un dièse est parcourue une unique fois, donc l’algorithme est en O(|V]), c’est-à-dire linéaire en le nombre de sommets.  ................ 1E = 22222225s = ............... HirrfrHt 222222 .............. 1111 E ............. 1111111111111.... ............ 111111111111111... ............. 1111111111111.... .............. 11111111111..... ......... 3 —Hs ........ 333...,.1111111.. 4dddddada..... ....... 33333....,11111,,, 444444444,,,,, ...... 3333333.....111.... 4éédéaaaa..... 233433353 = =- — da4aasaaa. ... 33353333333: =0 se mm en ddédddaaa. ...  FiGURE 6.3 - L'état de la grille avant et après exécution de l’algorithme sur le graphe de Clean Bandit. 
80 CHAPITRE 6. GRAPHES  Algorithme avec la structure union-find Comme le graphe est non orienté, la re- lation « il existe un chemin entre 1 et v » est une relation d’équivalence. Ainsi, les composantes connexes sont exactement les classes d’équivalence pour cette rela- tion, et union-find est une structure adéquate pour représenter notre problème, voir section 1.5.5 page 23.  Complexité La complexité de cette méthode est légèrement moins bonne que celle de notre première approche, néanmoins cette méthode s’avère nécessaire lorsqu’on dispose d'un graphe dont le nombre d'arêtes évolue et qu’on souhaite savoir à chaque instant le nombre de composantes connexes.  def nb_connected_components (graph) : | n = len(graph) | uf = UnionFind(n) Ë nb_components = n | for node in range(n): for neighbor in graph[node]: if uf.union(node, neighbor): nb_components -= return nb_components  Application à la déconnexion d’un graphe On vous donne un graphe dont les arêtes disparaissent progressivement au cours du temps, c’est-à-dire qu’on vous fournit une séquence e,,…, @ d'arêtes telle que l'arête e; disparaît au temps i.On souhaite déterminer l’instant à partir duquel le graphe n’est plus connexe. On part du graphe sans arêtes au temps ? = |E| (qui contient donc |V|composantes connexes). À chaque étape, on ajoute une arête et on regarde si le nombre de compo- santes a évolué. Dès que le nombre de composantes atteint 1, on sait que le graphe est connexe, par définition et t + 1 est la valeur cherchée.  FIGURE 6.4 - Une photo d’un dé et l'image postérisée. 
6.7. COMPOSANTES BI-CONNEXES 81  6.7 Composantes bi-connexes  Entrée  Sortie  Application Étant donné un graphe avec des coûts de sabotage pour chaque som- met et chaque arête, on veut déterminer un unique sommet ou une unique arête de coût minimal qui déconnecte le graphe. Notez la différence avec le problème de trouver un ensemble minimal d’arêtes qui déconnectent le graphe, qui est le problème de la coupe minimum, décrit en section 9.8 page 137.  Définition Soit un graphe non orienté connexe. — Un sommet de déconnexion — aussi appelé point d'articulation — est un sommet dont la suppression déconnecte le graphe. — Une arête de déconnexion — aussi appelé pont — est une arête dont la suppres- sion déconnecte le graphe, voir figure 6.7. — Une composante bi-connexe est un ensemble maximal d’arêtes, tel que pour le graphe induit (restreint à cet ensemble d’arêtes et aux sommets adjacents) il n'existe ni sommet de déconnexion ni d’arête de déconnexion. — Les arêtes qui ne sont pas des ponts se partitionnent en composantes bi- connexes. Une composante bi-connexe S à aussi la propriété que pour tout couple de sommets s,t € S il existe deux chemins de s à t qui passent par des sommets distincts. Notez que les composantes bi-connexes sont définis par une partition d'arêtes et non par une partition des sommets. En effet un sommet peut peut appartenir à plusieurs composantes bi-connexes, voir sommet 5 de la figure 6.6. Etant donné un graphe non orienté, le but est de le décomposer en composantes bi-connexes.  Complexité linéaire par un parcours en profondeur, voir [14].  Parcours en profondeur détaillé Dans une section précédente, nous avons décrit le parcours en profondeur (DFS) d’un graphe. Nous allons maintenant y ajouter des informations supplémentaires sur les sommets et les arcs. Tout d’abord, les sommets 
82 CHAPITRE 6. GRAPHES  Ficure 6.5 — Les sommets sont étiquetés par leur numéro d’ordre de traitement, tel qu'‘il est stocké dans dfs_num. Le style de ligne est solide pour les arcs de liaison, en tirets pour les arcs retour et en pointillés pour les arcs avant. Pour chaque arc de liaison (u, v) il existe un arc inverse de liaison (v, u) qui n’est pas montré pour plus de lisibilité.  sont numérotés dans l’ordre de leur traitement. Le tableau dfs_num contient cette information. Un graphe non orienté est représenté comme un graphe orienté par génération de deux arcs pour chaque arête. Le parcours DFS explore des arcs du graphe, que nous distinguons de la manière suivante, voir figure 6.5. Un arc (u, v) est :  un arc de liaison si v a été vu pour la première fois lors du traitement de u. Ce sont ces arcs qui forment l’arbre couvrant construit par le parcours, aussi appelé l’arbre DFS ;  un arc inverse de liaison si (v, u) est un arc de liaison ; un arc retour si v a déjà été vu et que c’est un antécédent de u dans l’arbre DFS ; un arc avant si v a déjà été vu et que c’est un descendant de u dans l’arbre DFS.  Pour un parcours en profondeur d’un graphe orienté, il existe un type supplémentaire d’arc, l'arc transversal, qui va vers un sommet déjà vu, mais qui est ni descendant ni antécédent. Comme dans cette section nous ne considérons que des graphes non orientés, nous pouvons ignorer ce type d’arc.  Déterminer le type d’un arc Notez que le type d’arc est facile à déterminer en comparant les valeurs dfs_num de ses extrémités, voir figure 6.5. Concrètement, en plus de dfs_num[v] pour chaque sommet v nous calculons une valeur dfs_low[v] qui est cruciale pour l’algorithme. Elle est définie comme le minimum de dfs_num[u] sur tous les arcs retour (w,uw) où w serait un descendant de v. Le minimum est donc pris sur les sommets u qui peuvent être atteints de v par une séquence d’arcs de liaison (qui peut être vide) suivie d’un arc retour. S’il n’y a pas un tel sommet, on pose dfs_low[u] = c.  Observation clé C’est cette valeur qui permet de déterminer les sommets et les arêtes de déconnexion. 
6.7. COMPOSANTES BI-CONNEXES 83  FIGURE 6.6 — Les sommets sont étiquetés par dfs_num suivi de dfs_low. En gras les sommets de déconnexion et l’arête de déconnexion.  1. Un sommet u qui est la racine de l’arbre DFS est sommet de déconnexion si et seulement s’il possède au moins deux fils dans l’arbre. Notez que chaque fils v satisfait dfs_low[v] > dfs_num[u].  2. Un sommet u qui n’est pas la racine est un sommet de déconnexion si et seulement s’il possède au moins un fils v avec dfs_low[v] = dfs_num[u].  3. Une arête (u, v) — à échange de u et v près — est une arête de déconnexion si et seulement si (u, v) est un arc de liaison et dfs_low[u] > dfs_num[v].  Pour déterminer les composantes bi-connexes, il suffit alors d’appliquer les défini- tions ci-dessus pour déterminer à quel moment débute une nouvelle composante bi-connexe.  FrGure 6.7 — Une arête entre deux sommets de déconnexion n'est pas toujours une arête de  déconnexion et les extrémités d’une arête de  déconnexion ne sont pas toujours des som- mets de déconnexion.  Détails d'implémentation Le tableau father contient le prédécesseur dans l’arbre DFS pour chaque sommet et permet aussi d'identifier les racines des arbres DFS. Pour chaque sommet u, nous comptons dans critical_childs[u] le nombre de fils v dans l’arbre tels que dfs_low[v] > dfs num[u]. Ces informations permettent en fin de parcours de déterminer les sommets et les arêtes de déconnexion. La valeur dfs_low{[v] est mise à jour pour chaque arc retour détecté émanant de v. En fin de traitement, cette valeur est propagée vers le sommet père dans l’arbre DFS. 
84 CHAPITRE 6. GRAPHES  # pour faciliter la lecture les variables sont sans préfixe dfs_ def cut_nodes_edges (graph) : n = len(graph) time = 0 num = [None] # n low = [n] #* n father = [None] # n # father[v] = | critical_childs = [0] * n # ce_childs[u] | times_seen = [-1] * n | for start in range(n): if times_seen[start] == -1: # initier parcours DFS times_seen[start] = 6 to_visit = [start] while to_visit: node = to_visit[-1] if times_seen[node] == 6: # début traitement  père de v, None si racine = nb fils v tq low[v] >= num[u]  num[node] = time time += 1l  low[node] = float('inf’) children = graph{node]  if times_seen[node] == len(children): # fin traitement to_visit.pop() up = father [node] # propager low au père  if up is not None: | low[up] = min(low[up], low[node]) if low[node] >= num[up]: critical_childs[up] += 1 else: child = children[times_seen [node]] # prochain arc times_seen(node] += 1  if times_seen[child] == -1: # pas encore visité father[child] = node # arc de liaison times_seen[child] = 0 to_visit.append(child) # (dessous) arc retour  elif num[child] < num{node] and father[node] != child: low[node] = min(low[node], num[{child]) cut_edges = []  cut_nodes = [] # extraire solution for node in range(n): if father[node] == None: # caractérisations  if critical_childs[node] >= 2: cut_nodes . append (node) else: # nœuds internes if critical_childs[node] >= 1: cut_nodes . append (node) if low[node] >= num[node]: cut_edges.append((father[node], node)) return cut_nodes, cut_edges 
6.8. TRI TOPOLOGIQUE 85  6.8 Tritopologique  © °‘3’°  entrée sortie  Définition Étant donné un graphe orienté G(V,A), on souhaite en ordonner les sommets selon une fonction de rang r de façon que pour chaque arc (u,v), on ait r(u) < r(v).  Application Le graphe pourrait représenter un ensemble de tâches où les arcs sont les dépendances u — v & «il faut exécuter u avant v ». On s’intéresse à un ordre d’exécution qui soit compatible avec les dépendances. Tout d’abord, plusieurs remarques. — Il existe plusieurs tris topologiques pour un même graphe. Par exemple, si la séquence s est un tri topologique de G, et la séquence t un tri topologique de Gz, alors par exemple les séquences st et ts sont des tris topologiques du graphe formé par la réunion de G; et G. — Un graphe contenant un cycle n’admet jamais de tri topologique : chaque sommet d’un cycle nécessite le traitement préalable de tous les autres. — Un graphe ne contenant pas de cycle admet au moins un tri topologique. Voir plus bas.  Complexité linéaire en la taille de l’entrée.  Algorithme par parcours DFS Si on ne traite chaque nœud qu’après tous ses des- cendants voisins, alors on obtiendra un ordre topologique inverse. En effet, si u — v est une dépendance, alors : — Soitva été parcouru avant u, auquel cas v a déjà été traité (sinon cela signifierait que u est accessible depuis v et que donc, le graphe contient un cycle) et l’ordre topologique inverse est respecté ; — soit v est parcouru à partir de u, auquel cas u sera traité après v, donc l’ordre topologique inverse est à nouveau respecté. Le parcours DFS décrit précédemment doit être adapté, car il ne permet pas de déterminer la date de fin de traitement des sommets. L'implémentation suivante utilise un tableau seen qui indique pour chaque som- met -1 s’il n'a pas encore été vu, sinon le nombre de descendants directs qui ont déjà été vus. Quand ce compteur est égal au nombre de fils du nœud, le traitement du nœud est terminé, et il est ajouté à la séquence order. Cette séquence contient un ordre topologique inverse, qu'il faudra inverser à la fin de l'algorithme. 
86 CHAPITRE 6. GRAPHES  def topological_order_dfs (graph) : ! n = len(graph) order = [] times_seen = [-1] * n for start in range(n): if times_seen[start] == -1: times_seen[start] = 0 to_visit = [start] while to_visit: node = to_visit[-1] children = graph[node] if times_seen[node] == len(children): to_visit.pop() order.append(node) else: i child = children[times_seen [node]] times_seen[node] += 1 if times_seen[child] == -1: times_seen[child] = 0 ! to_visit.append(child) | return order[::-1]  Algorithme glouton Une solution alternative est basée sur le degré entrant des sommets. Considérons un graphe acyclique. Intuitivement, on peut commencer par ajouter à la séquence solution les nœuds sans antécédent dans un ordre arbitraire, les élaguer du graphe, puis ajouter à la séquence les nouveaux nœuds sans antécédent dans un ordre arbitraire, et ainsi de suite. Ce procédé termine car un graphe acyclique contient toujours un sommet sans antécédent, et la suppression de nœuds préserve l’absence de cycle.  ‘ def topological_order (graph): V = range(len(graph)) indeg = [0 for _ in V] for node in V: # determiner degree entrant for neighbor in graph[node]: indeg[neighbor] += 1  Q = [node for node in V if indeg[node] == 0] order = [] while Q: node = Q.pop() # sommet sans arc entrant  order .append(node) for neighbor in graph[node]: | indeg[neighbor] -= 1 if indeg[neighbor] == 6: Q.append(neighbor) return order  Applications Étant donné un graphe acyclique et deux sommets s, t, on peut sou- haiter compter le nombre de chemins de s à t, ou connaître le chemin le plus long lorsqu'il y a des poids sur les arcs. Une solution en temps linéaire consiste à effectuer un tri topologique et dérouler un programme dynamique sur les nœuds dans cet ordre. 
6.9 COMPOSANTES FORTEMENT CONNEXES 87  Par exemple le programme dynamique P[s] = 0, P[v] = 1 + max, P[u] calcule le plus long chemin de s à t où la maximisation est faite sur les tous les arcs (u, v) entrant env.  6.9 Composantes fortement connexes  Définition Une partie À C V d'un graphe orienté est appelée composante fortement connexe si pour tout couple (u, v) de sommets de À, il existe un chemin dans À qui lie u à v. Notez que dans ce cas il existe aussi un chemin dans À qui lie v à u, voir figure 6.8.  Ficure 6.8 —- Un exemple de partition d’un graphe en composantes fortement connexes.  Observation Le graphe des composantes est défini comme le résultat de la contrac- tion des composantes fortement connexes en super sommets. Ce graphe est acyclique, car chaque cycle d’un graphe orienté est inclus dans une seule composante fortement connexe.  Complexité linéaire par un des algorithmes suivants.  L'algorithme de Tarjan L'algorithme de Tarjan [27] effectue un seul parcours en profondeur, et numérote les sommets dans l’ordre de leur début de traitement. Il place également les sommets rencontrés sur la pile waiting en attente d’être groupés en composante fortement connexe. Une fois une composante détectée, elle est élaguée du graphe et enlevée de la pile waiting. Cela signifie que tous les arcs entrant dans une composante déjà découverte sont ignorés. Un parcours en profondeur entre dans une composante C par un premier sommet vo, puis en explore tous les sommets et éventuellement aussi les composantes attei- gnables depuis C. La fin de traitement du sommet vp est effectuée après celle de tous ses descendants dans l’arbre DFS. Par construction, il est clair qu’au moment de la fin de traitement de vp, la pile waiting contient au-dessus de vy tous les sommets de C. Nous appelons ce sommet le représentant de C. Les sommets sont numérotés dans l'ordre de traitement, alors le représentant de C porte le plus petit numéro. Toute 
88 CHAPITRE 6. GRAPHES  la difficulté consiste à faire de sorte qu’on puisse déterminer le représentant d’une composante. Pour cela, l’algorithme calcule pour chaque sommet v deux valeurs, le numéro d’ordre du parcours en profondeur dfs_num[v] qui est attribué à chaque début de traitement du sommet et dfs min[v]. Cette dernière valeur est définie comme le minimum de dfs_num[u] sur tous les sommets u qui ne soient pas encore dans une composante fortement connexe — donc qui sont encore en attente — et qui sont atteignables à partir de v par une série d’arcs de liaison éventuellement suivie d'un unique arc retour. Cette valeur est donc définie comme suit, où le minimum est pris sur tous les arcs sortants (v, u) :  dfs_num[v] dfs_min[v] := min4 dfs_min[u] _ si (v,u) est un arc de liaison ; dfs_num[u] si (v,u) est un arc retour.  Notez que cette valeur diffère de dfs_low[v] décrite en section 6.7 page 81, où il n‘y avait pas la notion de sommets en attente et où dfs_low[v] pouvait prendre la valeur co. Considérons le cas d’une composante C sans arc sortant, et d'un sommet v € C. Soit A le sous-arbre DFS ayant pour racine v, et supposons qu'il existe un arc retour sortant de À vers un sommet u, prédécesseur de v. Comme C n’a pas d'arc sortant, u fait partie de C et v n’est pas le représentant de C. Dans ce cas, nous avons dfs_min[v] < dfs_numl[v]. S’il n’existe pas d’arc retour sortant de À, alors il contient une composante fortement connexe, et comme il est inclus dans C, cet arbre couvre C, et v est son représentant. Cette situation est détectée par dfs_min[v] == dfs_num[v].  Détails d'implémentation En parallèle à la pile waiting, l’algorithme maintient un tableau de booléens waits, qui permet de tester l’appartenance à la pile en temps constant. Ainsi, il est facile d’élaguer une composante en posant à Faux les entrées correspondantes. L’algorithme renvoie une liste de listes, contenant les sommets de chaque compo- sante. Notez que les composantes sont déterminées dans l’ordre topologique inverse. Cela nous sera utile pour la résolution d’une formule 2-SAT par la suite. 
6.9. COMPOSANTES FORTEMENT CONNEXES  ‘ def tarjan_recursif(graph): ! global sccp, waiting, dfs_time, dfs_num secp = [1 waiting = [] waits = [False] * len(graph) dfs_time = 0 dfs_num = [None] * len(graph)  def dfs(node): global sccp, waiting, dfs_time, dfs_num  waiting.append (node) # nouveau nœud attend waits[node] = True dfs_num{node] = dfs_time # marquer visite dfs_time += 1 dfs_min = dfs_num{node] # calculer dfs_min for neighbor in graph[node]: if dfs_num[{neighbor] == None:  dfs_min = min(dfs_min, dfs(neighbor)) elif waits[neighbor] and dfs_min > dfs_num[neighbor]: dfs_min = dfs_num[neighbor]  if dfs_min == dfs_num[node]: # représentant d'une composante ! sccp.append([1]) # créer composante | while True: # ajouter sommets qui attendaient  u = waiting.pop() waits[u] = False sccp[-1].append(u) if u == node: # jusqu'au représentant break return dfs_min  for node in range(len(graph)): if dfs_num[node] == None: dfs(node) return sccp  Version itérative La version itérative de l’algorithme est nécessaire pour de grands graphes, de l’ordre de 100 000 sommets. Ici un compteur times_seen permet à la fois de marquer les sommets rencontrés et mémoriser le nombre de voisins déjà  considérés. 
90 CHAPITRE 6. GRAPHES  def tarjan(graph): n = len(graph) dfs_num [None] # n dfs_min [n] # n waiting [l waits = [False] * n # invariant: waits[v] ssi v in waiting secp = [] # liste des composantes détectées dfs_time = 0 times_seen = [-1] * n for start in range(n): if times_seen[start] == -1: # initier parcours times_seen[start] = 6 to_visit = [start] while to_visit: node = to_visit [-1] sommet de la pile if times_seen[node] == 0: # début traitement dfs_num[node] dfs_min [node] dfs_time | dfs_time += 1 waiting.append(node) waits[node] = True children = graph[node]  3  n & H u 1 + H 3 œ  if times_seen[node] == len(children): # fin traitement to_visit.pop() # enlever de Ila pile dfs_min[node] = dfs_num[node] # calcul dfs_min  for child in children: if waits[child] and dfs_min[child] < dfs_min[node]: dfs_min[node] = dfs_min[child]  if dfs_min[node] == dfs_num[node]: # représentant % component = [] # créer composante while True: # ajouter sommets  u = waiting.pop() waits[u] = False component .append (u) if u == node: # jusqu’au repr. break sccp.append(component) else: child = children[times_seen [node]] times_seen[node] += 1 if times_seen[child] == -1: # pas encore visité times_seen[child] = 06 to_visit.append(child) return sccp  Algorithme de Kosaraju _ Unalgorithme différent a été proposé par Kosaraju [20]. Sa complexité est également linéaire, et en pratique tout à fait comparable à l’algorithme de Tarjan, en revanche il est peut-être plus facile à comprendre. Le principe consiste à faire un premier parcours en profondeur, puis un deuxième sur le graphe où on aurait inversé l’orientation des arcs. Formellement on note AT := ((v,u)|(u, v) € A} le résultat de l’inversion des arcs. L'algorithme se compose alors des étapes suivantes. 
6.9. COMPOSANTES FORTEMENT CONNÈ)ŒS 91  1. Faire un parcours en profondeur de G(V, A). Noter f[v] la date de fin de traite- ment du sommet ».  2. Faire un parcours en profondeur de G(V, AT), en choisissant les racines v dans l’ordre de f[v] décroissant.  Chaque arbre rencontré dans ce deuxième parcours est une composante fortement connexe.  La validité de l'algorithme repose sur l’idée que si on associe à chaque composante fortement connexe C l’entier F(C) := maxuec fu, alors F donne un ordre topologique sur le graphe induit par les composantes fortement connexes dans G(V, AT). Ainsi chaque arbre produit dans le deuxième parcours reste à l’intérieur d’une composante, car les seuls arcs sortants mènent à des composantes déjà parcourues.  Détails d'implémentation La liste sccp (pour strongly connected component) con- tient la liste des composantes fortement connexes.  \def kosaraju_dfs(graph, nodes, order, sccp): Ï times_seen = [-1] * len(graph) for start in nodes: if times_seen[start] == -1: # initier DFS to_visit = [start] times_seen[start] = 0 sccp.append([start]) while to_visit: node = to_visit[-1] Ï children = graph[node] | if times_seen[node] == len(children): # fin traitement Ë to_visit.pop() order .append(node) else: child = children[times_seen[node]] times_seen[node] += 1 if times_seen[child] == -1: # nouveau nœud times_seen[child] = 0 to_visit.append(child) sccp[-1].append(child)  \ def reverse(graph): i rev_graph = [[] for node in graph] for node in range(len(graph)): for neighbor in graph[node]: rev_graph[neighbor].append(node) return rev_graph  \ def kosaraju(graph): n = len(graph) order = [] scep = [] kosaraju_dfs(graph, range(n), order, [1]) kosaraju_dfs(reverse(graph), order[::-1], [], sccp) return sccp[::-1] # suivre l'ordre topologique inverse 
92 CHAPITRE 6. GRAPHES  6.10 2-satisfiabilité  Beaucoup de problèmes de décision peuvent se modéliser comme un problème de satisfaction d’une formule booléenne. Le problème de satisfaction est le suivant.  Définition On dispose de n variables booléennes. Un littéral est une variable ou la négation d’une variable. Une clause est une disjonction de plusieurs littéraux, ce qui veut dire qu’une clause est satisfaite si au moins un des littéraux est vrai. Une formule est la conjonction de plusieurs clauses, ce qui veut dire qu’'une formule est satisfaite si toutes les clauses sont satisfaites. Le but est de savoir s’il existe une affectation aux variables qui satisfait la formule. Une formule est dite de classe 2-SAT si chaque clause contient au plus deux littéraux. Un des résultats fondamentaux en informatique est qu’on peut vérifier en temps linéaire si une formule 2-SAT est satisfiable, tandis qu'en général (pour une formule 3-SAT par exemple), on ne connaît pas d'algorithme polynomial dans le pire cas.  FIGURE 6.9 — Graphe obtenu à partir d’une ins-  ävb tance de 2-SAT. Il y a deux composantes forte- bVvc ment connexes. Celle du bas pointe vers celle ave du haut, donc affecter Faux à tous les littéraux avc. du bas et Vrai à tous ceux du haut permet de  satisfaire cette formule.  Complexité linéaire, voir [3].  Modélisation par un graphe orienté La disjonction de deux littéraux x V y est équivalente à l’implication X > y voir y = x. On associe à une formule 2-SAT le graphe des implications, où les sommets sont les littéraux, et les arcs les implications équivalentes aux clauses, voir figure 6.9. Ce graphe présente une forte symétrie.  Observation clé On peut facilement montrer qu’une formule 2-SAT n'est pas satis- fiable si pour une variable x on a dans le graphe d’implication un chemin de x à x et un chemin de x à x. Ce qui est surprenant, c’est que la réciproque est également vraie. Les implications étant transitives, il est clair que si x > x = x est impliqué par la formule, alors elle ne peut pas être satisfaite, car chacune des affectations x =Faux ou x =Vrai aboutit à la contradiction Faux— Vrai. Réciproquement, supposons que toute variable est dans une autre composante fortement connexe que sa négation. Comme le graphe orienté est symétrique, on peut considérer des paires de composantes fortement connexes, au sein desquelles chacune contient toutes les négations des littéraux de l’autre. De plus, tous les littéraux d’une même composante fortement 
6.10. 2-SATISFIABILITÉ 93  connexe doivent avoir la même valeur booléenne. À partir de ce graphe, il est alors possible de déterminer une affectation qui satisfait la formule : il suffit d’affecter Vrai à tous les littéraux d’une composante dont aucun arc n’en sort. Il y en a forcement une, car le graphe des composantes est acyclique. Ensuite on affecte Faux à la composante opposée, on élague ces deux composantes du graphe et on recommence. Pour faire cela efficacement, notons que l’algorithme présenté ci-haut qui dé- termine les composantes fortement connexes les trouve dans l’ordre topologique inverse. Il suffit alors de parcourir les composantes dans cet ordre, affecter Vrai à chaque composante qui n’a pas encore de valeur et Faux à la composante opposée.  Détail d'implémentation Nous avons codé les littéraux par des entiers de sorte que +1,...,+n codent les n variables et —1,..., —n leurs négations. Une clause est codée par un couple d'entiers et une formule comme une liste de clauses. Chaque littéral correspond à un des 2n nœuds du graphe orienté associé. Ces nœuds sont numérotés de 0 à 2n — 1, où 2i code la variable x;,; et 2i+ 1 code x;+1. Voici le code correspondant.  \ def _vertex(lit): # codage sommet pour un littéral donné | if lit > 0: return 2 # (lit - 1) else: return 2 # (-lit - 1) + 1  | def two_sat (formula) : | # -- n est le nombre de variables Ï n = max(abs(clause[p]) for p in (0, 1) for clause in formula) graph = [[] for node in range(2 * n)]  for x, y in formula: #x ory graph[_vertex(-x)].append(_vertex(y)) # -x => y graph[_vertex(-y)].append(_vertex(x)) # -y =>x sccp = tarjan(graph) comp_id = [None] * (2 * n) # pour chaque nœud l'id. de sa comp.  affectations = [None] * (2 * n) for component in sccp: rep = min(component) # représentation de la composante for vtx in component: comp_id[vtx] = rep  | if affectations[vtx] == None: | affectations[vtx] = True | affectations[vtx * 1] = False # littéral complémentaire | for i in range(n): Ï if comp_id[2 * i] == comp_id[2 * i + 1]: | return None # formule insatisfiable  return affectations[::27 
7. Cycles dans les graphes  Plusieurs problèmes classiques concernent les cycles dans les graphes, qu'il s'agisse de déplacements géographiques, ou d’anomalies dans un graphe de dé- pendances. Les problèmes les plus simples consistent à détecter l’existence de cycles, ou bien l'existence de cycles de poids négatif, ou encore à déterminer un cycle de poids total minimal ou de poids moyen minimal. D'autres problèmes s’intéressent au calcul de chemins explorant tout le graphe en visitant chaque arête exactement une fois (chemin eulérien) ou, lorsque ce n’est pas possible, au moins une fois (problème du postier chinois). Ces problèmes sont polynomiaux, tandis que déterminer un cycle qui visite chaque sommet exactement une fois (cycle hamiltonien) est NP-difficile.  Algorithmes de recherche de cycles  détection de cycle O(IV| + |E|) parcours en profondeur cycle poids total minimal O(IVI - |E}) Bellman-Ford cycle poids moyen minimal O(IV|-|E]) algorithme de Karp cycle ratio minimal O(IV| - |E|-1log } t) _ recherche dich. chemin eulérien O(IVI + |EI) glouton cycle postier chinois O( VIV1 - |E}) couplage parfait poids minimal voyageur de commerce O(IVI2/Y1) programmation dynamique  7.1 Chemin eulérien 8 4 7 1 @ s; »  é_ 54  entrée sortie  Application Vous êtes dans la ville de Kaliningrad — anciennement Kônigsberg — et souhaitez savoir si vous pouvez faire une promenade qui traverse chaque pont exactement une fois et qui vous ramène sur votre point de départ. C’est cette situation qui a poussé Leonhard Euler à étudier ce problème en 1759. 
;  96 CHAPITRE 7. CYCLES DANS LES GRAPHES  Définition Étant donné un graphe connexe G(V, E) tel que chaque sommet a un degré pair, on souhaite trouver un cycle dans ce graphe qui traverse chaque arête exactement une fois. Le même problème peut être posé pour des graphes orientés et fortement connexes tels que le degré sortant de tout sommet est égal à son degré entrant.  Algorithme en temps linéaire Un graphe a un cycle eulérien si et seulement il est connexe et tout sommet est de degré pair. Ceci a été montré par Euler en 1736. De même un graphe orienté a un cycle eulérien si et seulement s’il est fortement connexe et que tout sommet a le même degré entrant que degré sortant. Comment trouver un tel cycle ? Hierholzer [13] à proposé en 1873 l'algorithme suivant. Prenez un sommet arbitraire et faites une marche à partir de v en marquant les arêtes traversées comme infranchissables. Le choix des arêtes empruntées peut être arbitraire. Cette marche retourne forcément vers le sommet v. Ceci découle du fait que les extrémités de la marche sont les seuls sommets adjacents à un nombre impair d’arêtes franchissables. Le résultat est un cycle C qui pourrait ne couvrir qu’une partie du graphe. Dans ce cas, comme le graphe est connexe, il existe un sommet v’ € C qui a encore des arêtes franchissables. Alors on effectue de nouveau une marche à partir de v’ et on insère dans C le cycle ainsi obtenu. La procédure est répétée jusqu’à ce qu’on obtienne un seul cycle eulérien. Pour que l’algorithme soit en temps linéaire, il faut que la recherche du sommet v' soit efficace. Pour cela nous découpons le cycle C en deux parties P et Q. Les sommets dans P n’ont plus d’arêtes franchissables adjacentes. À tout moment, tant que Q n’est pas vide, on enlève le sommet v au début de Q et on l’ajoute à la fin de P, ce qui revient à avancer sur le cycle. Ensuite on tente d'insérer à cet endroit un cycle passant par v. Pour cela, s’il y a une arête franchissable adjacente à v, on trouve par simple exploration un cycle R partant de v et revenant à v. Le cycle R est alors ajouté en début de Q. L'algorithme est en temps linéaire, car chaque arête n’est considérée qu’une seule fois.  Détails d’implémentation D’abord nous décrivons l'implémentation pour des gra- phes orientés. Pour faciliter la manipulation des données, P, Q, R sont représentés par des piles, codés par des listes. Le cycle courant se compose de la pile P, suivie de R, suivie de la liste miroir de Q. Pour trouver rapidement un arc sortant d’un sommet qui soit franchissable, on stocke dans next[node] le nombre d'’arcs sortant du sommet node qui ont déjà été franchis. Il suffit d'augmenter ce compteur quand on traverse l’arc vers le i-ème voisin de node, où i=next[node]. 
7.1. CHEMIN EULÉRIEN ; 97  def eulerian_tour_directed(graph) :  P= ( Q = [0] | R = [(l | next = [0] * len(graph) | while Q: E node = Q.pop() Î P.append(node) while next[node] < len(graph[node]): | neighbor = graph[node] [next [node]] next[node] += 1 R.append(neighbor) node = neighbor while R: Q.append(R.pop())  return P  La variante de cet algorithme pour des graphes non orientés est un peu plus subtile. Une fois l’arc (u, v) franchi il faut marquer l'arc (v, u) non franchissable. Nous avons choisi de stocker dans seen[v] l'ensemble des voisins u de v tel que (v, u) n’est plus franchissable. Pour plus d’efficacité, v n’est pas ajouté à seen[u] au moment de franchir l’arc (u, v), car à ce moment-là le compteur next[u] est augmenté et cet arc ne sera plus pris en compte.  Vdef eulerian_tour_undirected(graph):  P =[ Q = [6] | R = ( |  next = [0] * len(graph) seen = [set() for _ in graph] while Q: node = Q.pop() P.append(node) while next[node] < len(graph[node]): neighbor = graph[node] [next[node]] next[node] += 1 if neighbor not in seen[node]: seen[neighbor].add(node) R.append(neighbor) node = neighbor Ï while R: | Q.append(R.pop()) return P |  Variante chemin eulerien Si un graphe est connexe et que tous les sommets sont de degré pair, sauf deux sommets u, v qui sont de degré impair, alors il existe un chemin qui passe par tous les arêtes exactement une fois. Ce chemin commence en 4 ettermine en v. Il peut être trouvé par le même algorithme décrit précédemment, il suffit de débuter la marche sur u. Pour s’en convaincre, il suffit d’ajouter temporairement l’arête (u, v) et de chercher un cycle eulérien. 
98 CHAPITRE 7. CYCLES DANS LES GRAPHES  7.2 Problème du postier chinois  Application En 1962, le mathématicien Kwan Mei-Ko a travaillé comme facteur _ pendant la révolution culturelle chinoise. Il s’est alors posé le problème de trouver _ le plus court cycle dans un graphe qui explore chaque arête au moins une fois. C’est _ exactement le problème que doit résoudre un facteur qui doit distribuer le courrier dans un quartier d’une ville.  Définition On vous donne un graphe non orienté connexe G(V, E). Le but est de trouver un cycle dans le graphe qui emprunte chaque arête au moins une fois. Lorsque tous les sommets sont de degré pair, il s’agit de déterminer un cycle eulérien, problème traité dans la section précédente.  Complexité O(n°), voir [5].  Algorithme Le principe de l’algorithme consiste à travailler avec un multigraphe, c’est-à-dire un graphe qui peut admettre plusieurs arêtes entre deux mêmes sommets. L'idée est d’ajouter des arêtes pour rendre le graphe eulérien, et ensuite de produire un cycle eulérien. Les arêtes ajoutées doivent aider à relier les sommets de degré impair pour rendre le graphe eulérien. Comme on veut ajouter le moins d’arêtes possible, ces arêtes supplémentaires vont former une collection de chemins, qui vont coupler les sommets de degré impair. Le problème central consiste alors à calculer un couplage parfait dans le graphe complet des sommets de degré impair de V où le poids d’une arête (u, v) est égal à la distance entre u et v dans G. Le calcul de toutes les distances coûte O(7°) par l’algorithme de Floyd-Warshall. Puis un couplage parfait de poids minimal peut être calculé en temps O(n*) par l'algorithme de Gabow [9]. Cet algorithme est toutefois trop sophistiqué pour figurer dans ce livre.  7.3 Cycles de ratio poids sur longueur minimal - Karp  Un problème classique consiste à détecter des cycles négatifs dans un graphe. Une application possible est donnée comme exercice dans [4, ex.24-3 p.615]. Etant donné n devises avec leur taux de conversion, on souhaite déterminer un cycle qui permet de créer de la richesse en effectuant les échanges de devises le long du cycle. Mais dans cette section, nous nous intéressons à un problème un peu différent qui admet une solution élégante.  Définition Étant donné un graphe orienté pondéré, le but est de trouver un cycle C qui minimise le poids moyen sur ses arcs, soit  ZeeC w(e) CI 
7.3. CYCLES DE RATIO POIDS SUR LONGUEUR MINIMAL - KARP 99  Application Imaginons un système modélisé par un graphe dont les sommets sont les états et les arcs les transitions possibles. Chaque transition est étiquetée par une consommation de ressource. À chaque pas de temps le système est dans un état particulier et doit évoluer vers un autre état en suivant un arc sortant. Le but est de minimiser la consommation de ressources à long terme, et une solution optimale est un cycle de poids moyen minimal.  Algorithme de Karp en O(|V|-|E|)[16] L'algorithme suppose l’existence d’un som- met source à partir duquel tout sommet peut être atteint. Le cas échéant, un tel sommet peut toujours être ajouté au graphe. Comme on s’intéresse à la moyenne du poids des arcs d’un cycle, la longueur des cycles est aussi importante. C’est pourquoi plutôt que de calculer simplement des plus courts chemins, on souhaite déterminer pour chaque sommet v et chaque longueur € = 0,,n un plus court chemin de la source à v composé d’exactement € arcs. Cette partie est effectuée par programmation dynamique. Appelons d[€][v] le poids de ce plus court chemin. Initialement, d[0][v] vaut 0 pour la source et +o pour les autres sommets, puis pour chaque € = 1,...,n,  d[€][o] = mind[é — 1][u] + wuo  où la minimisation est prise sur les arcs entrants (u,v) de poids w,p. Notons par d[v] = mingen d[k][v] la distance de la source à v.  Observation clé Soit C un cycle de poids moyen minimal. Son poids À vérifie  à = min max éirlte] - dtélfo)  7.1 D k=0...,n-1 n—k (1)  Pour s’en convaincre, il suffit de suivre une série d’observations, qui débute par le casi=0. Nous devons montrer que la partie droite de (7.1) est zéro, ou de manière équi- valente que min _ max d[n][o] — d[Æ][v] = 0. v k=0,.,n-1 Comme À = 0, le graphe n’'a pas de cycle de poids négatif. Pour tout sommet v, il existe donc un plus court chemin de la source à v qui est acyclique et donc  d[v] = k=Ë—1 d[k][v].  En conséquence, ,foax _ d[n][o] — d{&][o] = d{n][o] — d{o].  .....  Pour tout sommet v on a d[n][v] > d[v] et donc  mänd[n][v] —d[v] = 0.  Il nous reste à exhiber un sommet v pour lequel on a l’égalité d[n][v] = d[v]. Soit u un sommet de C. Comme il n’existe pas de cycle négatif, il existe un chemin simple 
100 CHAPITRE 7. CYCLES DANS LES GRAPHES  P de poids minimal allant de la source à u. On complète P par autant de copies de C pour obtenir un chemin P de la source vers u de longueur au moins n. Comme C est de poids nul, P’ est encore un chemin vers w de poids minimal. Soit P” un préfixe de P* de longueur exactement n et v le dernier sommet de P”. P” est aussi un plus court chemin de la source vers v, voir figure 7.1. Donc d[n][v] = d[v] pour ce sommet, ce qui prouve l’égalité (7.1) dans le cas À = 0.  Ficure 7.1 — Ilustration de la preuve pour le cas À = 0. P est un chemin de poids minimal de la source © P (u) à u, et ajouter des copies du cycle C de poids nul laisse le chemin de poids minimal, donc tout sommet © rencontré sur le cycle est également atteint par un chemin de poids minimal.  Pour le cas À # O, nous faisons une observation intéressante. Si on enlève une valeur À du poids de chaque arc, la valeur À diminue également de A. Si on note d’ les distances dans le graphe ainsi modifié, on remarque les égalités suivantes :  d'In][o] — d'[k][o] _ d[n][o] - nà — (d'[K][o] - kA)  n—k n—k - dinlfoldf&][o] _ nA — kA “ n—k C n=k _ d{n][o]d{k][o] _ A n—k ’  ce qui montre que la partie droite de (7.1) diminue également de A. En choisissant pour À la constante À, on se ramène au cas À = 0, ce qui prouve l'égalité (7.1).  Détails d'implémentation La matrice dist contient les distances comme décrit ci- dessous. Elle est accompagnée de la variable prec qui marque le prédécesseur sur un plus court chemin. Après avoir renseigné ces matrices, une deuxième partie consiste à trouver la paire v, k qui optimise l’expression (7.1), suivie d’une troisième partie qui en extrait le cycle. Dans le cas où aucun cycle n’est atteignable à partir de la source, la fonction renvoie None. 
7.4. CYCLES DE RATIO COÛT SUR TEMPS MINIMAL 101  def min_mean_cycle(graph, weight, start=0): INF = float(’inf’) = len(graph) # calculer distances dist = [[INF] * n] prec = [[None] * n] dist[0][start] = 6 for ell in range(1, n + 1): dist.append([INF] # n) prec.append([None] * n) for node in range(n): for neighbor in graph[node]: alt = dist[ell - 1][node] + weight [node][neighbor] if alt < dist[ell][neighbor]: dist[ell][neighbor] = alt prec[ell][neighbor] = node # -- détecter valeur optimale valmin = INF argmin = None for node in range(n): valmax = -INF argmax = None for k in range(n): alt = (dist[n][node] - dist[k][node]) / float(n - k) # ne pas diviser par float(n-k) pour le cycle de poids total m1n if alt >= valmax: # par >= on cherche cycles simples valmax = alt | argmax = k | | if argmax is not None and valmax < valmin:  valmin = valmax argmin = (node, argmax) # -- extraire cycle if valmin == INF: # -- il n’y a pas de cycle du tout return None  C= ll node, k = argmin for 1 in range(n, k, -1): C.append(node) node = prec[1][node] return C[::-1], valmin  7.4 Cycles de ratio coût sur temps minimal  Définition Soit un graphe orienté, avec deux pondérations sur les arcs, des coûts c et des temps t. Les temps sont positifs ou nuls, mais les coûts arbitraires. Le but est de trouver un cycle qui minimise le ratio entre le coût total et le temps total. Cela s’appelle le problème du cargo à vapeur (tramp steamer problem).  Application Le capitaine d’un bateau de marchandise souhaite déterminer une route qui maximise son profit. Pour cela, il dispose du graphe complet couvrant différents ports et chaque arc (u, v) est étiqueté du temps pour rejoindre v à partir de u ainsi que du profit qu’il lui est possible de faire en achetant de la marchandise au 
102 CHAPITRE 7. CYCLES DANS LES GRAPHES  port u et en la revendant au port v. Le but est alors de trouver un cycle qui maximise le profit sur le temps total.  Algorithme par recherche dichotomique [1, sec.5.7]. Soit C un cycle. La valeur objective de C est au moins 6 si et seulement si  Lacc C(a) <6 Z c(a) < Z ôt(a), Z c(a) — ôt(a) < 0.  — , Z‘”EC (a) aeC aeC aeC  Donc pour détecter un cycle C qui a un ratio strictement meilleur que 6, il suffit de détecter un cycle négatif dans le graphe où les arcs a sont pondérés par c(a) — ôt(a). Ce test peut alors être utilisé dans une recherche dichotomique, pour obtenir la solution avec une précision demandée. Dans le cas où les coûts et temps sont entiers, une précision de 1/ }, t(a) suffit pour résoudre le problème de manière exacte. La complexité est alors de O(log(} t(a)) - [V| - |A]), où V est l’ensemble des sommets et A l’ensemble des arcs. Comme initialement on ne dispose pas de borne supérieure ou inférieure sur l’optimum 6*, on commence le test par 6 = 1. Tant que le test est négatif on multiplie 6 par 2, pour finalement obtenir un 6’ ou le test est positif et ainsi avoir une borne inférieure et supérieure sur l'optimum. Dans le cas où le test initial est positif, on procède de la même manière, mais en divisant par 2.  7.5 Voyageur de commerce  Définition Étant donné un graphe pondéré sur les arcs, on souhaite calculer un plus court chemin partant d'une source donnée et qui visite chaque sommet exactement une fois : un tel chemin est dit hamiltonien.  Complexité en O(|V|2""!) par programmation dynamique.  Algorithme Le problème de décision associé étant NP-complet, nous proposons une solution acceptable lorsque le nombre de sommets est petit, de l’ordre d’une vingtaine. Supposons que les sommets soient numérotés de 0 à n — 1, n — 1 étant la source pour faciliter les notations. Pour chaque ensemble S € {0,1,...,n — 2}, notons O[S][v] le poids minimal d’un chemin de la source à v passant exactement par tous les sommets de S puis terminant env, avecu & S. Pour le cas de base, O[0][v] est juste la longueur de l'arc de n —1 à v. Sinon O[S][v] pour $ non vide est le minimum de  O[S\{u}][u] + Wuv  sur tous les sommets u € S, où Wyy est le poids de l’arc (u, v). 
8. Plus courts chgmins  Un problème classique sur les graphes consiste à trouver un plus court chemin entre deux sommets, une source s et une destination v. Pour le même coût on peut obtenir les plus courts chemins entre la source s et toutes les destinations possibles v”, c’est pourquoi les algorithmes présentés dans ce chapitre s’intéressent au problème plus général des plus courts chemins pour une source unique dans un graphe orienté. La longueur d’un chemin est définie comme la somme du poids de ses arcs. Et la distance de s à v comme la longueur d’un plus court chemin entre s et v. Pour simplifier la présentation, nous montrons en général seulement comment calculer les distances. Pour obtenir un chemin qui réalise cette distance, il suffira de maintenir, en plus du tableau des distances, un tableau de prédécesseurs. Donc si dist[v] est la distance de s à v avec dist[v] = dist[u]+w[u][v] pour un sommet u, on stockera dans le tableau de prédécesseurs prec[v] = u. En suivant ces prédécesseurs jusqu’à la source s, on peut déterminer un plus court chemin en ordre inverse, qui a mené de la source à une destination donnée.  P.  : P, : 2 :  Ficure 8.1 — Un plus court chemin de s à v passant par u est la composition d'un plus court chemin de s à u et d'un plus court chemin de u à v.  8.1 Propriété de composition  Les plus courts chemins comportent une propriété de composition, qui est la clé des différents algorithmes de plus courts chemins *. Considérons un chemin P de s à v (on dit aussi s — v chemin), qui passe par un sommet u, voir figure 8.1. Il est donc la concaténation d'un chemin P, de s à u et d’un chemin P de u à v. La longueur de P est la somme des longueurs de P, et de P. Donc si P est un plus court chemin de s à v, alors P, doit être un plus court chemin de s à u et P; un plus court chemin de u à v. Tout simplement car le remplacement de P, par un chemin plus court résulterait en un chemin plus court de s à v.  2. Bellman l'appelle principe d'optimalité, qui est au cœur des problèmes de programmation dynamique. 
104 CHAPITRE 8. PLUS COURTS CHEMINS  Sommets noirs, gris et blancs Cette propriété de composition est à la base de l’al- gorithme de Dijkstra et de ses variantes, sur des graphes comportant éventuellement des poids positifs ou nuls sur les arcs. L'algorithme maintient un tableau dist censé contenir les distances de la source aux sommets, et contient +oo pour les sommets v dont aucun s — v chemin n’a encore été trouvé. Les sommets du graphe sont alors divisés en trois groupes, voir figure 8.2. Les sommets noirs sont ceux pour lesquels on connaît déjà un plus court chemin à partir de la source, les sommets gris sont les voisins directs d'un sommet noir, et les sommets blancs sont ceux pour lesquels aucun chemin n’est encore connu. Initialement, seule la source s est noire, avec dist/[s] = 0. Tous les voisins directs de s sont gris, dist[v] étant le poids de l'arc (s, v) et tous les autres sommets sont encore blancs. Puis l'algorithme va colorier de manière répétée un sommet gris en noir, en coloriant ses voisins blancs en gris, pour maintenir l’invariant. Finalement, tous les sommets atteignables de la source seront noirs, tandis que les autres resteront blancs.  FIGURE 8.2 — Exemple de coloration des sommets par l’algorithme de Dijkstra. Chaque sommet v est étiqueté par sa distance courante à la source dist[v] et les arcs spécifiés par prec sont affichés en gras.  Observation clé Quel sommet gris sera sélectionné pour être colorié en noir ? Le choix se porte sur un sommet v qui minimise dist[v]. Pourquoi ce choix est valide ? Considérons un s — v chemin P arbitraire. Comme s est noir et v est gris, il existe un premier sommet u sur ce chemin qui est gris. Donc P se décompose en un s-— u chemin P, et un u — v chemin P2, où P, ne contient que des sommets noirs intermédiaires, u exclu. Par le choix de v, dist[u]> dist[v], et par l’hypothèse que les poids des arcs sont positifs ou nuls, la longueur de P3 est positive ou nulle. Donc la longueur de P est au moins dist[v]. Comme le choix de P était arbitraire, ceci montre que le plus court s— v chemin est de longueur dist[v]. Colorier v en noir est donc valide, et pour maintenir l’invariant il faut pour chaque sommet v’ atteignable de v par un arc, colorier v’ en gris s’il ne l’était déjà et dist[v] + w[v][v”] est un nouveau candidat pour la distance dist[v”]. 
8.2. GRAPHES AVEC POIDS 0 OU 1 105  Algorithmes de plus courts chemins  sans poids O(|F|) parcours en largeur (BFS) poids 0 ou 1 O(IE|) Dijkstra adapté avec file à deux bouts poids positifsounuls O(|E|log|V|) Dijkstra poids arbitraires O(|VI - |E]) Bellman-Ford toutes les sources O(IVI*) Floyd-Warshall  Structure de données pour les sommets gris Comme à chaque itération on cherche un sommet gris v qui minimise dist[v], une file de priorité stockant les sommets v est adéquate, les valeurs de dist constituant les valeurs de priorité. C’est l'implémenta- tion qui est choisie pour l’algorithme de Dijkstra. Le retrait du sommet avec la plus petite distance se fait alors en temps logarithmique en le nombre de sommets. Si le graphe est plus simple, une structure plus simple peut être utilisée. Par exemple dans le cas où le poids des arcs est dans {0,1}, il n’y aura que deux types de sommets gris, ceux à une distance d et ceux à une distance d + 1. Cette file de priorité peut alors être implémentée de manière plus simple par une file à deux bouts, en anglais double-ended queue ou plus court deque. Cette file contient la liste des sommets gris dans l’ordre de priorité, et il suffit de retirer en temps constant à gauche un sommet v à colorier en noir. Les voisins de v seront ajoutés à la file à gauche ou à droite selon si l’arc correspondant est de poids 0 ou 1, voir 8.2. Finalement toutes les opérations de manipulation de la file coûtent un temps constant, et on économise un facteur logarithmique par rapport à l’algorithme de Dijkstra. Si le graphe est encore plus simple, avec des poids unitaires sur tous les arcs — donc un graphe non pondéré en quelque sorte — alors cette file à deux bouts peut être remplacée par une simple file, et on retrouve l’algorithme de parcours en largeur décrit précédemment en section 6.5 page 77.  8.2 Graphes avec poids 0 ou 1  Définition Étant donné un graphe dont les arcs sont pondérés par 0 ou 1, et un sommet source s, on veut calculer les distances de s vers les autres sommets.  Application Vous disposez de la carte d’un labyrinthe rectangulaire NxM composé d’obstacles et cherchez à en sortir en démolissant le moins de murs possible. Le labyrinthe peut être vu comme un graphe orienté dont les arcs (d’une case à une case adjacente) sont pondérés soit par 0 (accès à une case libre) soit par 1 (accès à une case contenant un mur). On cherche à démolir le moins de murs possible, donc à trouver le plus court chemin sur ce graphe du point de départ à la sortie.  Algorithme Reprenons la structure générique d’un algorithme de plus court che- min. À tout moment les sommets du graphe sont divisés en trois groupes, noir, gris et blanc. L'algorithme maintient une file à deux bouts contenant tous les sommets gris, plus éventuellement des sommets qui étaient gris au moment de leur insertion, mais devenus noir entre-temps. La file aura la propriété que pour une certaine valeur x, 
106 CHAPITRE 8. PLUS COURTS CHEMINS  tous les sommets noirs v satisfont dist[v] = x, et jusqu’à une certaine position tous les sommets gris v satisfont dist[v] = x et les suivants satisfont dist[v] = x + 1. Tant que cette file n’est pas vide, l’algorithme extrait un sommet v de la tête de file, qui a donc une valeur dist[v] minimale. Si ce sommet est déjà noir, il n’y a rien à faire. Sinon il est colorié en noir. Désormais pour maintenir l’invariant, il faut ajouter certains voisins v’ de v à la file. Soit € = dist[v] + w[v][v’]. Si v’ est déjà noir ou si dist[v”] < €, il n’y a aucune raison à ajouter v’ à la file. Sinon v’ peut être colorié en gris, dist[v’] est diminué de € et v’ est ajouté en tête de file si w[v][v'] = 0 ou bien en fin de file si w[v][v'] = 1.  def dist6®1(graph, weight, source=0, target=None): n = len(graph) dist = [floatC’inf')] *n prec = [None] # n black = [False] # n dist[source] = 0 gray = deque([source]) while gray: node = gray.pop() black[node] = True if node == target: break for neighbor in graph[node]: ell = dist[node] + weight [node] [neighbor] if black[neighbor] or dist[neighbor] <= ell: continue dist[neighbor] = ell prec[neighbor] = node if weight [node] [neighbor] == 0: gray.append(neighbor) else: gray.appendleft (neighbor) return dist, prec  8.3 Graphes avec poids positifs ou nuls — Dijkstra  Définition Étant donné un graphe orienté et des poids positifs ou nuls sur les arcs, on cherche un plus court chemin entre une source et une destination.  Complexité Une implémentation brutale est en O(IVÉ), et l’utilisation d’une file de priorité fait tomber la complexité à O(|E|log|V]). En utilisant une file de prio- rité Fibonacci, on peut obtenir une complexité de O(JE] + |V|1og [V]), voir [7], mais l'amélioration ne vaut peut être pas l’effort de son implémentation.  Algorithme Nous reprenons le formalisme introduit en section 8.1 page 104. L'al- gorithme de Dijkstra maintient un ensemble de sommets S pour lesquels on a déjà calculé un plus court chemin depuis la source. Donc S est précisément l’ensemble des sommets noirs. Initialement, S ne contient que la source elle-même. De plus, l’algorithme maintient un arbre de plus courts chemins enraciné en la source et qui 
8.3. GRAPHES AVEC POIDS POSITIFS OU NULS - DIJKSTRA 107  couvre S. On notera prec[v] le prédécesseur de v dans cet arbre et dist[v] la distance calculée. Voir figure 8.3.  …. .> FicuRE 83 - L'algorithme de Dijkstra capture ° D, les sommets via une boule de rayon croissant centrée en le sommet de départ. Il n’y a pas . ° . de sommets à distance 1, donc l’algorithme T e 7 saute directement au sommet le plus proche, . à distance 2, via la file de priorité.  Puis on s’intéresse aux arêtes du bord de S. Plus précisément, on considère les arcs (u,v) avec u dans S et v non dans S. Ces arcs définissent des chemins composés d’un plus court chemin de la source à u suivi de l’arc (u, v). Ce chemin a un poids qu'on affecte comme priorité de l’arc (u, v). Comme expliqué en section 8.1 page 104, l’algorithme extrait de la file de priorité un arc(u, v) de priorité minimale, donc qui définit un plus court s- v chemin. On peut ainsi ajouter l’arc (u, v) à l’arbre des plus courts chemins, ajouter v à S et itérer.  File de priorité L'ingrédient principal de l’algorithme est une file de priorité, qui est une structure de données contenant un ensemble d’éléments permettant d’ajouter des éléments et d’en extraire le plus petit. Une telle structure est implémentée en général par un tas (ici tas-min, voir 1.5.4 page 19), où ces opérations ont un coût logarithmique en la taille de l’ensemble.  Petite amélioration Pour plus d’efficacité, on ne stocke pas dans la file de priorité les arcs dont on sait déjà qu'ils ne mèneront pas à un plus court chemin. Pour cela, on met à jour dist[v] à chaque ajout d’un arc vers v dans la file. Ainsi au moment de considérer un arc on peut décider s’il permet de battre le meilleur chemin vers v connu à ce moment-là.  Détails d’implémentation Le code proposé permet de calculer les plus courts che- mins vers toutes les destinations, en omettant de spécifier une destination particulière à l'appel de la fonction. On stocke dans la file pour chaque arc sortant (u, v) le couple (d,v), où d est la longueur du chemin associé à l’arc. Il est possible qu’un sommet se trouve plusieurs fois dans la file avec des poids différents. Mais une fois la première occurrence de ce sommet extraite, il sera colorié en noir et les autres occurrences seront ignorées au moment de leur extraction. 
108 CHAPITRE 8. PLUS COURTS CHEMINS  from heapa import heappop, heappush  idef dijkstra(graph, weight, source=0, target=None): n = len(graph) { assert all(weight[u][v] >= 6 for u in range(n) for v in graph[ul) prec = [None] # n black = [False] * n dist = [floatC’inf’)] * n dist[source] = 6 heap = [(0, source)] while heap: dist_node, node = heappop(heap) # Le sommet le plus proche if not black[node]: black[node] = True if node == target: break for neighbor in graph[node]: dist_neighbor = dist_node + weight [node][neighbor] if dist_neighbor < dist[neighbor]: dist[neighbor] = dist_neighbor prec[neighbor] = node heappush(heap, (dist_neighbor, neighbor)) return dist, prec  Variante Si on dispose d’une file de priorité permettant de changer la priorité d’un élément, comme celle présentée en section 1.5.4 page 19, alors l'implémentation de l’algorithme de Dijkstra se voit légèrement simplifiée. Au lieu de stocker des arcs dans la file, on stocke les sommets, tous les sommets du graphe en fait, et la priorité d'un sommet v est dist[v]. Concrètement la file contient des paires de la forme (dist[v],v) et les compare de manière lexicographique. Quand un meilleur chemin vers v a été découvert on peut remplacer la paire correspondant à v avec une distance moindre. L'avantage de cette variante est qu’on peut alors se passer du marquage en noir des sommets. Pour chaque sommet 7, la file contient qu’une seule paire avec v, et quand (dist[v],v) est extrait de la file de priorité, on sait qu’on a découvert le plus court chemin vers v. 
8.4. GRAPHES AVEC POIDS ARBITRAIRES —- BELLMAN-FORD 109  from tryalgo.our_heap import OurHeap  def dijkstra_update_heap(graph, weight, source=0, target=None): n = len(graph) assert all(weight[u][v] >= 0 for u in range(n) for v in graph[u]) | prec = [None] # n dist = [floatC’inf')] * n | dist[source] = 0 | heap = OurHeap([(dist[node], node) for node in range(n)])  while heap: | dist_node, node = heap.pop() # Le sommet le plus proche { if node == target: break  for neighbor in graph[node]: old = dist[neighbor] Ï new = dist_node + weight [node][neighbor] if new < old: dist [neighbor] new prec[neighbor] node heap.update((old, neighbor), (new, neighbor)) return dist, prec  84 Graphes avec poids arbitraires — Bellman-Ford  Définition Pour ce problème, on autorise les poids sur les arcs à être négatifs. Dans le cas où il existe un cycle de poids négatif atteignable depuis la source, la distance de la source aux sommets du cycle est —co. En effet, en empruntant un nombre de fois arbitraire ce cycle, on obtient un chemin de la source de longueur arbitrairement petite. Cette situation gênante est détectée par l’algorithme que nous présentons.  Complexité en O(|V!| - |E|) par programmation dynamique.  Algorithme L'opération centrale est la relaxation des distances (voir figure 8.4, qui consiste pour chaque arc (u, v) à tester si l’utilisation de l’arc peut diminuer la distance de la source à v. La valeur d, + W est donc un candidat pour la distance d,. Ceci est fait en deux boucles imbriquées, la boucle interne relaxe les distances via chaque arc, et la boucle externe répète cette procédure un certain nombre de fois. Montrons qu’au bout de K itérations externes, on a calculé pour chaque sommet v la longueur d’un plus court chemin de la source à v, utilisant au plus k arcs. C’est vrai pour k = 0 et si on note dy[v] cette longueur pour tout k = 1,...,|V|-1, ona  dxi[0] = min _dy[u] + Wuv- ; u:(u, 0)eE  Détection de cycles négatifs Considérons la situation où le graphe ne contient pas de cycle négatif. Dans ce cas tous les plus courts chemins sont simples et donc |V|—1 itérations de la boucle suffisent à déterminer les distances vers toutes les destinations. Ainsi, si on observe un changement durant cette |V|-ième itération, cela signifie qu'il 
110 CHAPITRE 8. PLUS COURTS CHEMINS  Ficure 8.4 — Un exemple de relaxation via l’arête grisée. Emprunter cette arête raccourcit le chemin au nœud destinataire de l’arc.  existe un cycle négatif, atteignable à partir de la source. Notre implémentation renvoie un booléen indiquant la présence d’un tel cycle.  ! def bellman_ford(graph, weight, source=0): n = len(graph) | dist = [floatC’inf')] #* n prec = [None] # n dist[source] = 0 for nb_iterations in range(n+2): changed = False for node in range(n): for neighbor in graph[node]: alt = dist[node] + weight [node][neighbor] | if alt < dist[neighbor]: | |  Î t  dist[neighbor] = alt prec[neighbor] = node changed = True if not changed: # point fixe | return dist, prec, False return dist, prec, True  8.5 Toutes paires source-destination — Floyd-Warshall  Définition Étant donné un graphe pondéré sur les arcs, on souhaite calculer le plus court chemin entre chaque paire de nœuds. À nouveau, le problème est bien défini seulement en l’absence de cycles de poids négatifs, et l'algorithme détecte cette exception.  Complexité en O(n*) par l’algorithme de Floyd-Warshall.  Algorithme Les distances entre les sommets sont calculées par programmation dynamique, voir figure 8.6. Pour chaque k = 0,1,...,n une matrice carrée Wz est calculée qui contient en W;[u][v] la longueur du plus court chemin de u à v n‘utilisant que des sommets intermédiaires d’indice strictement inférieur à k, les sommets étant numérotés de 0 à n — 1. Donc pour k = 0, la matrice Wy contient seulement les poids des arcs, et +co aux entrées (u, v) pour lesquels il n’y a pas d’arc (u, v). La mise à jour est simple par le principe de composition : un plus court chemin de u à v passant par 
8.5. TOUTES PAIRES SOURCE-DESTINATION — FLOYD-WARSHALL 111  — n=l Auv = Zbukxckv W1(Ë-l) =I}Æäïw(u2+W‘(îc? k=0 A = BC w(€+l) - W€w£  FicurE 8.5 — L'algorithme de Floyd-Warshall peut être vu comme une multi- plication de matrices sur l'algèbre tropicale (R, min, +). Mais on ne peut pas se servir des algorithmes de multiplication rapide usuels car il s’agit seulement d’'un semi-anneau.  un sommet k est composé d'un plus court chemin de u à k et d’un plus court chemin de k à v. On calcule donc, pour k,u,v € {0,...,n — 1} :  Wzemlu][o] = min{W;[u][o], Wz[u][k] + W;[k][o]}.  Il s’agit bien du même k en indice et en position dans le tableau : pour le calcul de Wr+1[u][v], on a le droit de considérer des chemins passant par k.  9 9  FIGURE 8.6 — Un exemple de relaxation via le sommet k. Passer par k permet de raccourcir le chemin de u à v.  Détail d’implémentation L'implémentation maintient une unique matrice W qui joue le rôle des W; successifs et modifie la matrice des poids passée en paramètre. Elle détecte la présence de cycles négatifs et renvoie Faux dans ce cas.  def floyd_warshall (weight): V = range(len(weight)) for k in V: for u in V: for v in V: weight[u][v] = min(weight[u][v], weight[u][k] + weight [k] [v]) for v in V: if weight[v][v] < 8: # cycle négatif détecté return True return False 
112 CHAPITRE 8. PLUS COURTS CHEMINS  Détection de cycles négatifs Il existe un cycle négatif passant par un sommet v si et seulement si W,[v][v] < 0. Mais comme montré par Hougardy dans [7], il est plutôt conseillé de détecter les cycles négatifs avec l’algorithme de Bellman-Ford. En effet, en présence de cycles négatifs, les valeurs calculées par l’algorithme de Floyd-Warshall peuvent avoir une valeur absolue exponentiellement grande dans le nombre de sommets du graphe, et causer un débordement de capacité des variables.  8.6 Grille  Problème Étant donné une grille rectangulaire, dont certaines cases sont inacces- sibles, on souhaite trouver un plus court chemin de l’entrée à la sortie.  L’algorithme Ce problème est résolu de manière facile par un parcours en largeur sur le graphe décrit par la grille. Mais plutôt que de construire explicitement le graphe, il est plus simple de faire le parcours directement sur la grille. La grille est donnée sous forme d’un tableau à 2 dimensions contenant le caractère # pour les cases inaccessibles et un espace pour les cases accessibles. Notre implémentation utilisera ce même tableau pour marquer les sommets déjà visités, et évite ainsi une structure de données supplémentaire. Les cases visitées contiendront alors un des symboles  1>" 'e", 'V, ‘1’, précisant le prédécesseur sur un plus court chemin à partir de la source. jdef dist_grid(grid, source, target=None): { rows = len(grid) cols = len(grid[6])  dir = [C6, +1, ?>’), (0, -1, *<*), (+1, O, ’w’), C-1, O, *4°J] i, j = source grid[i][j] = ’s’  Q = deque() Q.append(source) while Q: il, j1 = Q.popleft() for di, dj, symbol in dir: # explorer toutes les directions i2? = il + di j2 = j1 + dj if not (0 <= i2 and i2 < rows and O <= j2 and j2 < cols): break # bord de la grille dépassé if grid[i21[j2] != ’ ’: # case inacc. ou déjà visitée continue grid[i2][j2] = symbol # marquer visite if (i2, j2) == target: grid[i2](j2] = ’t’ # but atteint return  Q.append(C(i2, j2))  Variantes L'implémentation proposée est facile à modifier pour autoriser des pas diagonaux sur la grille, entre des cases partageant seulement un coin. Une grille hexagonale est traitée de manière similaire en la transformant en une grille aux cases carrées, mais avec un voisinage particulier. La figure 8.7 illustre cette transformation. 
113  FiGure 8.7 — Représenter une grille hexagonale par une grille carrée.  8.7 Variantes  Le problème du plus court chemin étant central en algorithmique, il en existe de nombreuses variantes classiques que nous présentons dans cette section.  8.7.1 Graphe non pondéré  Dans un graphe non pondéré, il suffit d'effectuer un parcours en largeur pour déterminer les plus courts chemins.  8.7.2 Graphe orienté acyclique  Un tri topologique, cf. section 6.8 page 85 permet de traiter les sommets dans un ordre convenable : pour calculer la distance de la source à un sommet v, on peut s’appuyer sur les distances vers tous les prédécesseurs de v calculées au préalable, et appliquer une simple règle de programmation dynamique.  Application Donnée en exercice dans [4, ex.24-6 p.618]. On souhaite déterminer un parcours de son domicile à son travail, qui monte puis qui descend, pour se dépenser puis se reposer. Pour cela on dispose d’une modélisation de la ville en graphe, les sommets étant les points d’intersection avec leur élévation, et les arêtes les routes avec leurs longueurs.  8.7.3 Plus long chemin  La programmation dynamique mentionnée ci-haut peut être adaptée pour cal- culer les plus longs chemins dans un graphe orienté acyclique (DAG en anglais). Pour un graphe général, ce problème consiste à trouver un plus long chemin de la source à la destination, qui passe par chaque sommet au plus une fois. Ce problème est NP-difficile, on ne connaît pas d'algorithme qui le résolve en temps polynomial. Si le nombre de sommets est petit, de l’ordre d’une vingtaine, on peut utiliser une méthode de programmation dynamique sur les sous-ensembles de sommets S et calculer D[S][v], la longueur du plus long chemin de la source à v en n’utilisant que des sommets intermédiaires dans S. On a alors pour tout S non vide la relation  DIS][o] = max DIS \ u][u] + w[ul[e], 
114 CHAPITRE 8. PLUS COURTS CHEMINS  où S \ u est l’ensemble S amputé du sommet u et w[u][v] le poids de l’arc (u, v). Le cas de base est alors  w[u][v] si l’arc (u, v) existe —0 sinon.  D[0][v] = {  8.74 Plus long chemin dans un arbre  Souvent, les problèmes qui sont difficiles en général deviennent faciles sur des arbres, car la notion de sous-arbre permet une solution par programmation dyna- mique. C’est aussi le cas du problème du plus long chemin dans un arbre, dont l'algorithme linéaire est exposé en section 10.3 page 152.  8.75 Chemin qui minimise le poids maximal sur les arcs  Dans le cas où le but n’est pas de minimiser la somme des poids des arêtes d’un chemin, mais le maximum, une solution simple est d'utiliser la structure union-find. On part du graphe vide, et on y ajoute les arêtes par poids croissant jusqu’à ce que source et destination soient dans une même composante connexe. Pour les graphes orientés, une approche similaire fonctionne également, mais elle est plus fastidieuse à mettre en œuvre.  8.7.6 Graphe pondéré sur les sommets  Considérons un graphe dont les sommets sont pondérés à la place des arcs, et le but est de trouver un chemin de la source à la destination qui minimise la somme du poids des sommets traversés. Cette situation peut se ramener au cas précédent en remplaçant chaque sommet v par deux sommets v" et v* reliés par un arc du poids de v, et en remplaçant chaque arc (u, v) par un arc (u*,v”).  8.7.7 Chemin qui minimise le poids maximal sur les sommets  Dans le cas où le poids d’un chemin est défini comme le maximum sur les poids des sommets intermédiaires du chemin, on peut utiliser l’algorithme de Floyd- Warshall. Il suffit d'ordonner les sommets par poids croissant et arrêter l’itération dès qu’un chemin de la source à la destination a été trouvé. Il suffit de maintenir un tableau de booléens de connectivité C;[u][v] qui indique s’il existe un chemin de u à v en n’utilisant que des sommets intermédiaires d’indices strictement inférieurs à k. La mise à jour devient alors  Cz[u][o] = Cr-1[w][o] V (Cr-1[W][K] A Cz-1[K][v])-  8.7,8 Toutes les arêtes appartenant à un plus court chemin  Étant donné un graphe pondéré et une source s et une destination t, il peut exister plusieurs plus courts chemins entre s et t. Le but est de déterminer toutes les arêtes qui font partie d’un plus court chemin. Pour ce faire, à l'aide de deux appels à 
8.7. VARIANTES ‘ 115  l'algorithme de Dijkstra en s et en t, on calcule pour chaque sommet v la distance d[s, v] à partir de la source et la distance d[v, t] vers la destination. Puis une arête (u, v) fait partie d’un plus court chemin si et seulement si  d[s, u] + w[u, v] + d[v, t] = d[s, t],  où w[u, v] est le poids de l’arête.  Variante pour un graphe orienté Dijkstra n'admet qu’une source donnée au lieu d’une destination. Donc pour le calcul de toutes les distances d[v,t] vers t, il est nécessaire d’inverser temporairement les arcs. 
9. Couplages et flots  Traditionnellement, le cœur de l’optimisation combinatoire est constitué des pro- blèmes de couplage et de flots. Ces problèmes sont reliés et existent en plusieurs variantes. Le principe des algorithmes est une amélioration répétée d’une solution, initialement vide, qui aboutira à une solution optimale. Supposons que dans le graphe biparti de la figure 9.1 on souhaite déterminer un couplage parfait, c'est-à-dire associer à tout sommet de gauche un sommet de droite distinct. Les arêtes du graphe indiquent quelles associations sont autorisées. Si on commence par associer up à vy, alors on se retrouve bloqué. Pour obtenir le couplage parfait, on est forcé de défaire cette association. Ce principe est illustré dans les sections suivantes.  1}3 530 » @ ®  FrcuRE 9.1 — Construction progressive d’un couplage parfait.  Les algorithmes de flot présentés ici ont besoin que pour chaque arc (u, v), l’arc inverse (v, u) existe également. Is vont alors au préalable appeler la fonction suivante, qui complète le graphe si besoin avec des arcs inverses de capacité 0. Elle teste pour chaque arc (u, v) si u est présent dans la liste d'adjacence de v, ce qui lui donne la complexité O(JE| - [V]), ce qui est négligeable dans notre cas.  | def add_reverse_arcs(graph, capac): for u in range(len(graph)): for v in graph[u]: if u not in graph[v]: graph[v] . append (u) capac[v][u] = 6  Complexité des algorithmes présentés Dans le tableau suivant, on suppose pour les graphes bipartis G(U, V, E) que |U]| < |V|. Nous notons C la capacité maximale. 
| {  118 CHAPITRE 9. COUPLAGES ET FLOTS  couplages biparti sans poids O(IVI - |F1) chemins augmentants biparti avec poids O(IVP*) Kuhn-Munkres biparti avec préférences O(IVP*) Gale-Shapley flots capacités bornées O(|V| - |E|- C) Ford-Fulkerson capacités bornées O(IV|-|E|-logC) flots bloquants binaires capacités arbitraires O(IVI - |Ef?) Edmonds-Karp capacités arbitraires O(IVP - |E}) Dinic coupes graphe arbitraire O(IVI* - 1EI) Dinic graphe planaire O(|E|1og |V1) Dijkstra  9.1 Couplage maximum biparti  Application Affecter n cours à n salles, affecter n tâches à n ouvriers, les appli- cations au problème de couplage biparti maximum sont nombreuses. Il existe une version pondérée de ce problème qui sera décrite en section 9.2 page 121. Comme les applications se modélisent la plupart du temps avec des graphes bipartis, nous nous limitons ici à ce cas, mais ce problème peut également être résolu dans d’autres  graphes.  Définition Soit un graphe biparti G(U, V, E) avec E € U x V. Un couplage est un ensemble M C E tel qu'’il n’y ait pas deux arêtes dans M avec une même extrémité. Le but est de trouver un couplage de cardinal maximal. Pour M fixé on dit qu’un sommet est couplé s’il est dans une des arêtes de M, sinon le sommet est libre.  Observation clé _ Une approche naturelle pour aborder un problème d’optimisation est de partir d’une solution naïve et de tenter de l’améliorer jusqu’à ce qu'’elle soit optimale. Pour comprendre comment on peut améliorer un couplage, observons la différence symétrique entre deux couplages. Soit un couplage bleu M et un couplage rouge M'. La différence symétrique M @ M' est définie comme M\M' U M'\M. Elle est composée de cycles et de chemins aux couleurs alternant rouge et bleu le long des arêtes. Par un argument de comptage on s’aperçoit que si |M'| > |M| alors il existe 
9,1. COUPLAGE MAXIMUM BIFARTI 119  toujours un chemin alternant P commençant et terminant avec une arête rouge, voir  sE  OO000009 M  99900000  FIGURE 9.2 - La différence symétrique M®M est composée d’un cycle alternant, d'un chemin P augmentant pour M' et de deux chemins augmentants pour M.  Par rapport à M, ce chemin P commence et termine avec un sommet libre et alterne entre des arêtes qui ne sont pas dans M et des arêtes de M. On appelle un tel chemin augmentant, car la différence M @ P est un couplage avec une arête de plus que M. Ainsi si M n'est pas encore un couplage de cardinal maximal, alors il existe un chemin augmentant pour M. Pour être plus précis, s’il existe un couplage M’ avec IM'| > |M| et un sommet u € U couplé dans M' mais libre dans M, alors il existe un chemin augmentant P pour M débutant en u.  Algorithme en O(JUI-|EI) Cette observation nous mène vers un premier algorithme. Débutant par le couplage vide M, tant que c’est possible, chercher un chemin aug- mentant P pour M, et remplacer M par M @ P. Un tel chemin augmentant peut être trouvé simplement par un parcours en profondeur. Il suffit de partir d'un sommet libre de U, et considérer tous ses voisins v pas encore visités. Si v n’est pas encore couplé, le chemin qui a mené de la racine à v est un chemin augmentant. Si v est couplé à un sommet u’, alors on continue l’exploration à partir de u”. Trouver puis appliquer un chemin augmentant coûte alors O(|F]), et il faut faire ce travail pour au plus |U| sommets, la complexité de cet algorithme est alors O(JU| - |F|). 
120 CHAPITRE 9. COUPLAGES ET FLOTS  Détails techniques Dans l’implémentation ! qui suit, le couplage est codé par un tableau m qui associe à chaque sommet v € V le sommet de U auquel il est couplé, ou m[v] = None si v est libre. Le graphe est donné par un tableau E tel que E[u] est la liste des voisins de u. Les ensembles de sommets U, V donnés doivent être de la forme [0,1 ,/JU|-1] et [0,1,.….,VI-11.  def augment(u, bigraph, visit, match): | for v in bigraph[u]: if not visit[v]: visit[v] = True if match[v] is None or augment(match[v], bigraph, visit, match): match[v] = u # chemin augmentant trouvé return True return False  \ def max_bipartite_matching (bigraph) : | n = len(bigraph) # même domaine pour U que pour V | match = [None] * n for u in range(n): | augment (u, bigraph, [False] * n, match) return match  D'autres algorithmes Il existe un algorithme de Hopcroft et Karp qui résout le problème du couplage maximum dans un graphe biparti en temps O( v]V] - |E|). Son principe est de découvrir plusieurs chemins augmentants avec un même parcours, et de privilégier les chemins augmentants courts. Alt, Blum, Mehlhorn et Paul [2] ont trouvé un algorithme en O(JVI*S J|E|/1og |V]) qui est intéressant pour les graphes denses (avec beaucoup d'’arêtes). Mais tous ces algorithmes sont longs à implémenter et pas tant plus rapides en pratique que celui présenté dans cette section. ;  Problème de couverture minimale dans un graphe biparti Étant donné un graphe biparti G(U, V,E), on cherche un ensemble de cardinal minimal S C UU V tel que chaque arête (u,v) € E ait au moins une extrémité dans S, donc u € S ou v € S. Comme chaque arête d’un couplage doit avoir au moins une extrémité dans S, la taille du couplage maximal est une borne inférieure sur la taille minimale dans la couverture minimale. Le théorème de Kônig dit qu'il y a en fait égalité entre les valeurs optimales. La preuve du théorème est constructive et donne un algorithme pour trouver une couverture minimale à partir d’un couplage maximal M pour le graphe G(U, V, E). Soit Z l'ensemble des sommets non couplés par M, et qui sont dans U. Ajoutons dans Z tous les sommets atteignables à partir de Z par des chemins alternants. On définit un ensemble S = (U\Z)U(VN2).  1. Comprenez-vous pourquoi seuls les sommets de V sont marqués lors de leur visite ? 
9.2. COUPLAGE PARFAIT DE POIDS MAXIMAL —- KUHN-MUNKRES 121  La construction de Z implique que pour chaque arête (u,v) € M, si v € Z alors u € Z également. Et si u € Z, comme u n’était pas initialement dans Z avec les sommets libres de U, u a été ajouté à Z grâce à l’arête du couplage, donc v € Z. Ceci implique que pour chaque arête du couplage (u, v) € M, soit chacune de ses extrémités est dans Z soit aucune ne l’est. Donc toute arête du couplage a exactement une extrémité dans S, et |S| = |M|. Montrons que S couvre toutes les arêtes du graphe. Soit (u, v) une arête du graphe. Si u é Z, l'arête est couverte, alors supposons que u € Z. Si (u, v) n’est pas une arête du couplage, alors le caractère maximal de Z fait que v doit aussi être dans Z, et est donc dans S. Si (u,v) est dans M, alors par l’observation précédente v € Z, et donc v € S. Ceci montre que S est une couverture de sommets, ce qui implique que |S| < |M], ainsi nous pouvons conclure que |S| = |M|.  9.2 Couplage parfait de poids maximal - Kuhn-Munkres  vO vi v2 v3 vO vi v2 v3 vo|3 31 2| w/8 31 2 u10 4-83| ut|o 448 wu|o 23 0 u3|-1 1 2 +  entrée  Application Dans un département d’enseignement il y a n cours à distribuer à n enseignants. Chaque enseignant a donné ses préférences sous forme d’une pondé- ration des cours. Cette pondération est normée à un total de 1, pour être équitable entre enseignants. Le but est de trouver une bijection entre cours et enseignants, qui maximise le poids de préférence total sur toutes les affectations. Ce problème est le problème de couplage parfait dans un graphe biparti à profit maximal.  Définition Soit un graphe biparti G(U, V, E) avec une pondération w : E — IR sur les arêtes. Sans perte de généralité on suppose |U| = |V| et que le graphe est complet, c’est-à-dire E = U x V. Le but est de trouver un couplage parfait M C E qui maximise le poids }m W(e) (aussi appelé profit).  Variantes Une variante de ce problème est de calculer un couplage parfait de coût minimal. Dans ce cas il suffit de changer le signe des poids et de résoudre le problème de profit maximal. Si |U] > |V], il suffit d’ajouter à V de nouveaux sommets, reliés à tous les sommets de U par un poids 0. Il y à alors correspondance entre les couplages parfaits de ce nouveau graphe et les couplages maximaux du graphe original. Si le graphe n’est pas complet, il suffit de le compléter avec des arêtes de poids —oo, elles ne seront jamais sélectionnées par une solution optimale. 
122 CHAPITRE 9. COUPLAGES ET FLOTS  Complexité en temps O(JVI*) par l’algorithme Kuhn-Munkres aussi appelé méthode hongroise.  Algorithme C’est un algorithme qui entre dans la classe de ce qu’on appelle les al- gorithmes primal-dual, qui portent ce nom par une modélisation par programmation linéaire. La description donnée ici se veut simple, et ne va pas utiliser le vocabulaire de la programmation linéaire. L'idée principale consiste à considérer un problème lié, celui des étiquettes valides minimales. Les étiquettes sont des poids € sur les sommets, et elles sont valides si pour toute arête (u, v) '(u) + E(v) > w(u, v). (9.1)  Le problème lié consiste à trouver des étiquettes valides de poids total minimal. En sommant l'inégalité sur toutes les arêtes (u, v) d’un couplage on voit bien que le poids de tout étiquetage valide majore le poids de tout couplage parfait. Les étiquettes / définissent l’ensemble Ez constitué de toutes les arêtes (u, v) telles que '(u) + €(v) = w(u,v).  Le graphe restreint à ces arêtes est appelé le graphe d'égalité. Si on dispose d’étiquettes valides € et d'un couplage parfait M C Ez alors la valeur de }, ( est égale à la valeur de |M|. Comme la valeur de F ;, € majore celle de tout couplage parfait, ceci prouverait que M est de poids maximal. Le but est de produire un tel couple €, M. À tout moment, l’algorithme a des éti- quettes valides € ainsi qu’un couplage M C Ez. Puis de manière répétée, l’algorithme va augmenter le couplage M, ou si ce n’est pas possible améliorer les étiquettes (diminuer leur valeur totale).  Augmenter le couplage Pour augmenter le couplage, on doit chercher un chemin augmentant dans le graphe d’égalité. Pour cela, on construit un arbre alternant. On commence par choisir un sommet libre u; € U donc pas encore couplé par M. Ce sommet sera la racine de l’arbre. L'arbre alterne entre sommets de U et de V. Il alterne aussi d’un niveau à l’autre entre les arêtes de E;\M et les arêtes de M. Un tel arbre peut être construit par exemple par un parcours en profondeur. Si jamais une des feuilles v est libre à son tour, alors le chemin de u; à v est un chemin augmentant, et il est possible d’augmenter M le long de ce chemin, et d'augmenter |M| de 1. Dans ce cas, la construction de l’arbre alternant peut être interrompue.  Propriétés de l'arbre alternant Considérons la situation où le couplage n’a pas pu être augmenté. On a alors construit un arbre alternant À enraciné en un sommet libre u;, avec la propriété qu’aucun des sommets de V N À n’est libre. Appelons Ay les sommets de U couverts par l’arbre et Ay les sommets de V couverts par A. Nous avons alors |Au| = 1+ |Ay|. En d’autres termes, Au est composé de la racine de l’arbre plus les sommets couplés à chaque sommet de Ay. 
9.2. COUPLAGE PARFAIT DE POIDS MAXIMAL —- KUHN-MUNKRES 123  (#)  30  3(u) 1>4 0() C3  Fraure 9.3— Les étapes de l'algorithme de Kuhn-Munkres. (1) Le graphe biparti décrit par la matrice de poids donnée en début de section, avec les étiquettes des sommets, les arêtes du graphe d’égalité et en gras les arêtes d’un couplage. En pointillés, les arêtes d’un arbre alternant maximal enraciné en us. Cet arbre couvre les ensembles de sommets Au, Ay et ne peut plus être étendu. (2) L'amélioration des étiquettes fait apparaître une nouvelle arête (u,,v3). (3) L'arbre alternant inclut le sommet libre v3. (4) Le couplage peut être augmenté le long du chemin de u3 à v3.  Améliorer l'étiquetage Il faut être prudent en améliorant l’étiquetage. D’une part les étiquettes doivent rester valides, et d’autre part l’arbre A doit rester dans le graphe d'égalité. Donc si jamais on diminue l’étiquette de la racine u; d’une valeur A > 0 alors il faut augmenter de la même valeur ses descendants dans l’arbre, ce qui permet de diminuer de A à leur tour leurs descendants. Finalement l’amélioration consiste à diminuer de À les étiquettes de tous les sommets dans Ay et augmenter de A les étiquettes des sommets dans Ay. La valeur totale de l’étiquetage diminuera alors strictement puisque |Au| > |Av|. On observe que la différence entre partie gauche et droite de l’expression (9.1) — appelée marge — est préservée lorsque u, v € À ou u,v & À, donc l’arbre À reste dans le graphe d'’égalité. Pour la validité des étiquettes, il suffit de s'intéresser aux arêtes (u, v) avec u € Ay et v & Ay. Donc on peut choisir A comme la marge minimale sur ces arêtes, cf. figure 9.3.  Progrès Ceci va avoir l’effet favorable qu’une arête supplémentaire entre Ay et V\Ay va entrer dans le graphe d'égalité. C’est le cas notamment de l’arête qui a déterminé la marge minimale À, car sa marge devient alors 0. Notez que d’autres arêtes de ce graphe peuvent disparaître, mais c’est sans importance pour l’algorithme.  Initialisation Pour amorcer l’algorithme, on débute avec un étiquetage valide € et un couplage M vide. Pour faire simple, on choisit ((v) = 0 pour tout v € V et '(u) = maxpey W(u, v) pour tout u € U. 
124 CHAPITRE 9. COUPLAGES ET FLOTS  Une implémentation en temps O([V]*) Notre implémentation comporte une boucle extérieure sur des sommets u; € U, dont l'invariant énonce que les sommets déjà vus sont tous couplés par M. Cette boucle a pour complexité un facteur O([V|). Ensuite, pour chaque sommet libre u;, on va tenter d'augmenter le couplage en construisant un arbre alternant, ou d’améliorer les étiquettes si c’est nécessaire. La construction de l’arbre alternant coûte O(JVI*). Après chaque amélioration des étiquettes, l’arbre alternant augmente, en particu- lier |Ay| augmente strictement. Mais |Ay| est bornée par |U], donc l'augmentation du couplage coûte O(IVI*), et la complexité complète est O(|V]*).  \def improve_matching(G, u, mu, mv, au, av, lu, lv): assert not au[u] au[u] = True for v in range(len(6G)): if not av[v] and G[u][v] == lu[u] + lv[v]: av[v] = True if mv[v] is None or \ improve_matching(G, mv[v], mu, mv, au, av, lu, 1v): mv[v] = u mu[u] = v return True return False |  ‘def improve_labels(G, au, av, lu, 1v): | U = V = range(len(G)) Î delta = min(min(lu[u] + 1v[v] - G[u][v] for v in V if not av[v]) for u in U if au[u]) for u in U: if (au[u]): lu[u] -= delta for v in V: if Cav[v]): lv[v] += delta  def kuhn_munkres (G) : # couplage parfait de profit maximal en O(n"4)  assert len(G) == len(G[6]) n = len(G) Ï mu = [None] # n # couplage vide 1 mv = [None] # n | lu = [max(row) for row in G] # étiqu. triviaux | lv = [0] # n | | for u0 in range(n): | if mu[u0] is None: # sommet libre while True: au = [False] #* n # arbre alternant vide Ï  av = [False] * n if improve_matching(G, u0, mu, mv, au, av, lu, 1v): break improve_labels(G, au, av, lu, 1lv) return (mu, sum(lu) + sum(lv)) 
9.2. COUPLAGE PARFAIT DE POIDS MAXIMAL —- KUHN-MUNKRES 125  Détails d’implémentation Nous codons les sommets de U par les entiers de 0 à n — 1 et il en est de même pour V. Ceci nous force à coder € dans deux tableaux lu et Iv, un pour chaque ensemble U, V. De même mu et mv stockent le couplage, avec mu[u] = v, mv[u] = v si u € U est couplé avec v € V. Les sommets libres sont indiqués avec mu[u] = None ou mv[v] = None. Finale- ment, les tableaux booléens au et av précisent si un sommet est couvert par l’arbre alternant.  Une implémentation en temps O(JV*) Pour obtenir un temps de calcul cubique, il faut maintenir un tableau margeVal pour faciliter le calcul de A dans l’amélioration de l’étiquetage. Pour chaque sommet v € V\Ay on aura donc  margeVal, = min €(u) + C(v) — w(u, v). ueAu  Dans un autre tableau margeArg on stocke le sommet u qui a minimisé l’expression. Avec ces tableaux, il est plus facile d’augmenter l’arbre, car il n’est plus nécessaire de le reconstruire à partir de la racine à chaque changement d’étiquettes. L'arbre n’est ainsi plus construit avec un parcours en profondeur mais en sélectionnant de nouvelles arêtes de la manière suivante. On choisit l'arête (u, v) qui minimise la marge avec u € A et v £ À. Si la marge n’est pas zéro, alors on peut mettre à jour les étiquettes comme expliqué plus haut, ce qui a pour effet de réduire à zéro la marge de (u, v). Dans tous les cas (u, v) est une arête qui peut être ajoutée à l’arbre. Deux cas sont à distinguer. Soit v n’est pas couplé, alors nous avons détecté un chemin augmentant. Soit v est couplé à un sommet disons u”, dans ce cas on peut aussi ajouter (u’, v) à l’arbre À, et donc u’ à Au et il faut mettre à jour les marges en temps linéaire et recommencer. Ainsi, un chemin augmentant est trouvé en temps quadratique. Une fois qu’un sommet de U devient couplé, il le restera, il n‘y a donc que |V| chemins augmentants à trouver. La complexité finale est bien O(JV*).  Détails d'implémentation Cette fois-ci on aimerait connaître pour chaque som- met dans V s’il fait partie dans l’arbre, et si c’est le cas, quel est son prédécesseur. L'arbre n’étant pas construit par un parcours en profondeur, cette information doit être stockée dans un tableau. Elle nous permet de remonter à la racine au moment d'augmenter le couplage. Nous notons Av[v] le prédécesseur de v € Ay, et posons Av[v] = None pour tout sommet v € V\Ay en dehors de l’arbre. Le nom de tableau débute par une capitale pour le distinguer du tableau de booléens codant Au. Les tableaux margeVal et margeArg seront représentés par un seul tableau marge contenant les couples (val, arg). 
126 CHAPITRE 9. COUPLAGES ET FLOTS  {def kuhn_munkres(G) : # couplage parfait de profit maximal en O(n*3) | assert len(G) == len(G[0]) # matrice carrée n = len(G) U = V = range(n) mu = [None] # n # couplage vide mv = [None] # n lu = [max(row) for row in G] # étiqu. triviaux lv = [6] * n for root in U: # constr. un arbre alterné n = len(G)  au = [False] # n au[root] = True Av = [None] # n marge = [(lu[root] + 1v[v] - G[root][v], root) for v in V] ! while True:  ((delta, u), v) = min((marge[v], v) for v in V if Av[v] == None) assert au[u] if delta > 0: # arbre est complet for u0 in U: # améliorer les étiquettes  Ï if au[u0]: | lu[u0] -= delta for v6 in V: if Av[v0] is not None: 1v[v6] += delta else: (val, arg) = marge[v6] marge[v60] = (val - delta, arg)  assert lu[u] + lv[v] == G[u][v] Av[v] = u # ajout (u, v) dans A if mv[v] is None: break # chemin alt. trouvé...  | ul = mv[v] Ï assert not au[u1]  | au[u1] = True # ajout (ul, v) dans A | for v1 in V: if Av[v1] is None: # mettre à jour les marges  alt = (lu[u1] + 1lv[v1] - G[u1][v1], u1) if marge[v1] > alt: marge [v1] = alt  while v is not None: # ...chemin alt. trouvé u = Av[v] # le long du chemin vers la racine prec = mu[u] mv[v] = u # augmenter le couplage mu[u] = v v = prec  return (mu, sum(lu) + sum(lv))  Couplage parfait minimisant l’arête de poids maximal  Définition À nouveau, on nous donne un graphe biparti G(U, V, E) pondéré sur les arêtes w : E — Z et le but est de trouver un couplage parfait M. Mais au lieu de minimiser la somme des poids des arêtes de M, on souhaite minimiser le poids maximum d’une arête de M, donc calculer miny maxcem wW(e) sur tous les couplages parfaits. 
9.3. COUPLAGE PLANAIRE SANS CROISEMENT 127  Réduction vers le problème de couplage maximal Supposons que les arêtes soient ordonnées par poids croissant. Pour un seuil k donné on pourrait tester s’il existe un couplage parfait dans le graphe restreint aux k premières arêtes. Cette propriété étant monotone en K, il est possible de résoudre le problème par recherche dichotomique sur l’intervalle [|V|, |FI]. Cependant en tirant profit du fonctionnement particulier de l’algorithme cher- chant un couplage parfait, on peut se passer de la recherche dichotomique et écono- miser un facteur O(log |F]) dans la complexité. L'algorithme cherche à construire un couplage parfait en augmentant le couplage courant par un chemin augmentant, trouvé en construisant une forêt d’arbres al- ternants. On initialise la variable k à 0 et on maintient un couplage M et un arbre alternant pour M dans le graphe composé des k premières arêtes. Lorsque l’arbre est complet sans avoir abouti à un chemin augmentant, on incrémente k et on ajoute la k-ième arête au graphe en augmentant éventuellement l’arbre alternant. Chaque aug- mentation du couplage M coûte un temps linéaire en le nombre d'arêtes actuels, donc trouver le plus petit k tel que le graphe admet un couplage parfait a une complexité de O([VI - |E}).  9.3 Couplage planaire sans croisement  e O O  .o ° XD ce  entrée sortie  Définition On vous donne n points blancs et n points noirs dans le plan. On suppose que l'entrée n’est pas dégénérée, c'est-à-dire qu'il n’existe pas trois points colinéaires. Le but est de trouver un couplage parfait entre les points blancs et les points noirs, de telle sorte que si l’on relie tous les couples de points avec des segments droits, ceux-ci ne se croisent pas.  Observation clé Imaginons un instant que les points soient couplés mais que des segments se croisent. Considérons deux paires couplées u — v et u’ — v’ dont les segments se croisent, voir figure 9.4. Si on change leurs affectations en u — v’ et u’ — v, leur segments ne se croisent plus. Est-ce que cette opération améliore la solution d’une certaine façon ? On note que le nombre de croisements peut avoir augmenté après ce décroisement, comme en figure 9.4. Ce n’est donc pas la bonne mesure de progrès. En revanche, la longueur totale des segments a diminué. Cette observation nous mène vers un premier algorithme. 
128 CHAPITRE 9. COUPLAGES ET FLOTS  FiGURE 9.4 — Décroiser deux segments du couplage.  Algorithme sans garantie de performance Coupler les points entre eux de manière arbitraire. Puis tant qu‘il existe deux paires couplées dont les segments se croisent, les décroiser. Par l’observation précédente, cet algorithme est garanti de trouver une solution. Nos expériences ont montré de plus que les performances étaient bonnes en pratique. ;  Algorithme en O(n°) par réduction au couplage parfait de coût minimal On définit le graphe biparti G(U, V, E) complet, où U sont les points blancs, V les points noirs et pour tout u € U et v € V le coût de l’arête (u, v) est la distance euclidienne entre les deux points. L'observation précédente montre que le couplage parfait à coût minimal est forcément sans croisement. Donc il suffit d’appliquer l’algorithme de Kuhn-Munkres sur ce graphe pour résoudre le problème. Mais on sent bien que trop de travail est fait ici, car un couplage sans croisement n’est pas nécessairement de coût minimal.  Algorithme en O(nlogn) Il existe un algorithme bien meilleur qui repose sur le théorème du sandwich au jambon. Montré en 1938 par Banach, celui-ci énonce que si on dispose de n points noirs et n points blancs dans le plan dans une position non dégénérée, alors il existe une droite telle que de chaque côté il y ait autant de points noirs et autant de points blancs (exactement |n/2] de chaque). Concrètement, si n est impair, la droite passe par un point blanc et un point noir, et si n est pair, la droite ne croise aucun point. Un algorithme de Matousek, Lo, et Steiger de 1994 [22] permet de trouver cette droite en temps O(n). Cependant, il est long à implémenter. En quoi cela nous aide ? Avec la garantie qu'il n’y a pas trois points colinéaires, on obtient la propriété suivante. Soit n est pair, et la ligne séparatrice ne traverse aucun point, soit n est impair et la ligne séparatrice traverse exactement un point blanc et un point noir. Dans ce cas il est correct de les coupler. Et dans tous les cas on peut itérer sur les deux sous-instances indépendantes définies par la ligne. Cette décomposition récursive a une profondeur O(log, n), donc l’algorithme final est bien en O(nlog n). 
9.4 MARIAGES STABLES —- GALE-SHAPLEY 129  94 Mariages stables - Gale-Shapley  Définition Soit n femmes et n hommes. Chaque homme a fait une liste de préfé- rences sur les femmes, et les femmes ont fait pareil pour leurs préférences quant aux hommes. Un mariage est un couplage parfait dans le graphe biparti complet entre les hommes et les femmes. Il est appelé stable s’il n’existe pas un homme ; et une femme j, tel que l'homme préfère 7 à la femme avec laquelle il est associé, et la femme préfère i à l'homme avec lequel elle est associée. Le but est de calculer un mariage stable à partir des 2n listes de préférences. La solution n’est pas forcément unique.  Complexité en temps O(n°) par l’algorithme Gale-Shapley [11].  Algorithme L'algorithme débute sans couple marié. Puis tant qu’il y a des hommes célibataires, l’algorithme choisit un homme ; célibataire et la femme / que ; préfère parmi toutes les femmes qu'il n’a pas encore considérées. L'algorithme cherche alors à marier / avec j. Cette action va être effectuée si ÿ est encore célibataire, ou que / est marié avec un homme Kk, mais qu'elle préfère ; à k. Dans ce cas k rejoint les hommes célibataires.  Analyse Pour la complexité, tout couple i, j est considéré au plus une fois par l’algorithme. Et le travail à faire par couple est constant. Pour la validité, il suffit d’observer qu’au cours de l’algorithme, (1) une femme donnée est mariée à des hommes qu'’elle préfère de plus en plus, (2) alors qu’un homme donné est marié à des femmes qu'il préfère de moins en moins. Donc pour une preuve par contradiction de la validité de l’algorithme, supposons qu'à la fin, il existe un homme ; marié à une femme j’ et une femme j’ mariée à un homme ? tel que ; préfère ; à j’ et-j préfère i à ÿ’. Par l’observation (2), à un moment donné l’algorithme a considéré le couple i, j. Mais par (1), l’algorithme n’a jamais marié / avec j. Ça veut dire qu'au moment où le couple (i, j) est considéré, j devait être marié avec un homme k qu'elle préfère à i. Ce qui contredit le fait qu'à la fin elle soit mariée à un homme qu'elle aime moins que .  Détails d'implémentation Dans notre implémentation, les hommes sont numéro- tés de0 à n—1, et les femmes aussi. L'entrée se compose de deux tableaux. Le tableau men contient pour chaque homme la liste des n femmes par préférence décroissante. Le tableau women contient les listes des préférences des femmes. Le tableau women est d’abord transformé en un tableau rang qui contient pour chaque femme / et chaque homme / le rang de préférence que donne ; à i. Par exemple si rang[j][i] = 0 alors ; est l'homme préféré de la femme ;, et si rang[j][i"] = 1 alors ÿ’ est le deuxième choix de j, etc. Finalement mari associe aux femmes les hommes auxquels elles sont couplées. Et la file celib contient les hommes encore à marier, et pour chaque homme ;, suiv[i] indique l’indice de la prochaine femme à considérer dans sa liste de préférences. 
l  130 CHAPITRE 9. COUPLAGES ET FLOTS  ‘def gale_shapley(men, women): = len(men) assert n == len(women) suiv [6] * n mari [None] “ n rang [[0] “* n for j in range(n)] # construire rang for j in range(n): for r in range(n): | rang[j][women[j][r]] = r celib = deque(range(n)) # tous les hommes sont célibataires while celib: # tant qu'il y a des hommes à mettre en couple i = celib.popleft() j = men[i][suiv[ill suiv[i] += 1 if mari[j] is None: mari[j] = i elif rang[j][mari[j]] < rang[j][il: celib.append(i)  Ï else: | celib. put(marl[J]) # désolé pour mari[j] ! mari[j] = i return mari  9.5 Flot maximum par Ford-Fulkerson  ASTEF e FNF  44  Application Trouver les goulots d’étranglements dans l’évacuation des passagers d’un avion, résistance d’un réseau électrique ; de nombreux problèmes se modélisent comme un flot maximum dans un graphe.  Définition Formellement, on nous donne un graphe orienté G(V, A) avec des ca- pacités sur les arcs c : A — R* et deux sommets distincts : une source s € V et un puits à € V. Sans perte de généralité on suppose que pour tout arc (u, v) l’arc (v, u) est également présent dans le graphe, puisqu’on peut toujours ajouter des arcs de capacité zéro sans modifier la solution optimale. Un flot est une fonction f : A > R, 
9.5. FLOT MAXIMUM PAR FORD-FULKERSON 131  qui satisfait Y(u,v) € À : f(u,v) = —f(v,u), (9.2) qui respecte les capacités Ve € À : f(e) < c(e), (9.3) et qui respecte la conservation des flots en tout sommet, hormis la source et le puits Vo € V\(s, #} : Z flu,v) =0. (9.4) u:(u, v)EA  La valeur du flot est la quantité qui sort de la source, , f(s, v). Le but est de trouver un flot de valeur maximale.  Pour des graphes non orientés on remplace chaque arête (u, v) par les deux arcs (u,v) et (v, u) de même capacité.  Graphe résiduel et chemins augmentants Pour un flot f donné on considère le graphe résiduel qui se compose de tous les arcs (u, v) de capacité résiduelle c(u, v) — f(u, ) positive. Dans ce graphe, un chemin P de s à t est appelé un chemin augmentant, car on peut augmenter le flot le long des arêtes de ce chemin d’une valeur A qui est la capacité résiduelle minimale sur les arcs de P. Pour préserver (9.2) lorsqu’on augmente f(u, v), il faut diminuer f(v, u) du même montant.  L'algorithme de Ford-Fulkerson en O(|V|-|A|-C) où C = maxzez c(a). Pour que cet algorithme termine, les capacités doivent être entières. De manière répétée, il cherche un chemin augmentant P et augmente le flot le long de P. La recherche d’un chemin se fait avec un parcours en profondeur, et la seule garantie qu’on a est qu’à chaque itération le flot augmente strictement. La valeur d’un flot est clairement majorée par nC ce qui donne la complexité attendue.  Détails d'implémentation Dans l'implémentation que nous présentons, le graphe est donné sous forme de liste d'adjacence, mais pour faciliter la manipulation des flots, le flot est représenté par une matrice F à deux dimensions. La procédure augment tente d’augmenter le flot le long d’un chemin de u à target avec une valeur au plus val. La procédure renvoie le montant de l’augmentation du flot en cas de succès et zéro sinon. Elle cherche ce chemin via un parcours en profondeur et utilise le marqueur visit pour cela. 
132 CHAPITRE 9. COUPLAGES ET FLOTS  | def _augment (graph, capacity, flow, val, u, target, visit): J visit[u] = True if u == target: return val for v in graph[u]: cuv = capacity[u][v] if not visit[v] and cuv > flow[u][v]: # arc franchissable res = min(val, cuv - flow[u][v])  Î  delta = _augment(graph, capacity, flow, res, v, target, visit) if delta > 6: flow[u][v] += delta # augmenter le flot  flow[v][u] -= delta j return delta return 0  | def ford_fulkerson(graph, capacity, s, t): add_reverse_arcs(graph, capacity) n = len(graph) flow = [[0] * n for _ in range(n)] INF = float('inf’) while _augment (graph, capacity, flow, INF, s, t, [False] * n) > 0: pass # corps de boucle vide return (flow, sum(flow[s])) # flot, valeur du flot  Amélioration en O(|V! - |A] - log C) par l’algorithme des flots bloquants binaires Voir [12]. Une amélioration possible de cet algorithme consiste à ne pas augmenter bêtement le premier chemin trouvé, mais d’essayer d'augmenter d’une grande valeur à chaque fois. Concrètement, soit C la plus grande capacité sur les arêtes du graphe, et À la plus grande puissance de 2 inférieure ou égale à C. Alors on pourrait chercher de manière répétée à augmenter le flot avec des chemins augmentants de capacité résiduelle au moins À. Quand ce n’est plus possible, on peut recommencer avec une valeur de À deux fois plus petite et ainsi de suite. La dernière phase avec À = 1 termine lorsque s et à sont déconnectés dans le graphe résiduel, donc l’algorithme calcule bien un flot maximum. À la première phase, par la définition de C, on sait que le flot maximum est borné par|V|-C. Donc la première phase ne peut trouver qu’au plus n chemins augmentants. La recherche d’un tel chemin coûte O(|V| + |A]). Comme il n’y a que log, C phases, la complexité totale de cet algorithme est O(|V! - |A] - 1og C). 
9.6. FLOT MAXIMUM PAR EDMONDS-KARP 133  9.6 Flot maximum par Edmonds-Karp  Observation L'algorithme Ford-Fulkerson n’est pas polynomial, il est exponentiel en la taille de l’entrée. Heureusement, il existe une petite transformation qui le rend sa complexité polynomiale et indépendante de C. Ce qui est étonnant, c’est que si on applique d’abord les chemins augmentants les plus courts, alors le même algorithme aura une complexité indépendante de la capacité maximale. L'idée est que la longueur du plus court chemin augmentant augmente strictement toutes les |E| itérations au plus. Ceci est dû aux observations suivantes. — Soit L; le graphe par niveaux, où s est au niveau O, tous les sommets attei- gnables de s par un arc non saturé! forment le niveau 1 et ainsi de suite. Il s’agit donc d’un sous-graphe acyclique du graphe résiduel. — Un plus court chemin de s à t dans le graphe résiduel est forcément un chemin dans le graphe par niveaux. Si on augmente le flot le long de ce chemin, un des arcs devient saturé. — Une augmentation le long d’un chemin peut rendre non saturés des arcs, mais seulement des arcs vers des niveaux inférieurs. Ainsi les arcs qui deviennent franchissables ne peuvent pas diminuer la distance de s à v en nombre d’arcs. Par symétrie, la distance de v à t ne peut pas diminuer non plus. — Au bout d’au plus |E] + 1 itérations, un arc (u, v) et son inverse (v, u) ont été saturés. Ceci prouve que le sommet u a changé de niveau entre-temps, et par l’observation précédente la distance de s à t a strictement augmenté. — Comme il n’y a que n niveaux, il n’y a que |V| - |E| itérations au total. — La recherche d’un plus court chemin se fait par BFS en temps O([V]). La complexité totale est O(|V! - |E[*).  Algorithme Edmonds-Karp en O(V] - JEP) À partir du flot vide, tant qu’il existe des chemins augmentants, augmenter le long d’un plus court chemin augmentant.  Détails d'implémentation Les plus courts chemins augmentants sont trouvés avec un parcours en largeur d’abord (BFS), à l’'aide d’une file Q. Le tableau P a deux fonctions. D'une part, il sert à marquer les sommets déjà visités pour le parcours BFS*. Et dans ce cas plutôt que de marquer simplement avec un booléen, nous gardons en mémoire le prédécesseur du chemin de la source vers le sommet concerné. Ainsi on peut remonter à la source en suivant les valeurs de P pour augmenter le flot le long du chemin. Le tableau A contient pour chaque sommet visité v la capacité résiduelle minimale le long du chemin de la source à v. Avec ce tableau, on détermine de combien on peut augmenter le flot le long du chemin augmentant trouvé.  1. Un arc est saturé si le flot atteint la capacité de l’arc. 2. Comprenez-vous pourquoi il faut marquer P[source] ? 
134 CHAPITRE 9. COUPLAGES ET FLOTS  def _augment (graph, capacity, flow, source, target): n = len(graph)  A = [6] #n # A[v]=cap.res.min sur chemin source-v augm_path = [None] # n # None = sommet pas encore visité Q = deque() # parcours BFS  Q.append(source) augm_path[source] = source A[source] = float(’inf’) while Q: u = Q.popleft() for v in graph[u]l: cuv = capacity[u][v] residual = cuv - flow[u][v] if residual > © and augm_path[v] is None: augm_path[v] = u # stocker prédécesseur A[v] = min(A[u], residual) if v == target: break else: Q.append(v) return (augm_path, A[target]) # chemin augmentant, cap. res. min  def edmonds_karp(graph, capacity, source, target): | add_reverse_arcs(graph, capacity) V = range(len(graph)) flow = [[0 for v in V] for u in V]  while True: augm_path, delta = _augment (graph, capacity, flow, source, target) if delta == break v = target # remonter vers la source while v != source: u = augm_path[v] # et augmenter le flot | Î  flow[u][v] += delta flow[v][u] -= delta v=u return (flow, sum(flow[source])) # flot, valeur du flot  9.7 Flot maximum par Dinic  Algorithme de Dinic en O(|V[*-|E|) L'idée de l'algorithme de Dinic qui a été trouvé en même temps que le précédent est la suivante. Plutôt que de chercher un ensemble de chemins augmentant un par un, jusqu’à ce que la distance entre s et t augmente, on trouve un tel flot avec un seul parcours. Ceci ramène la complexité à O(JV[* - |E}). Imaginez une fonction dinic(u,val) qui tente de faire passer un flot de u à t dans le graphe par niveaux. La restriction est que ce flot ne dépasse pas val. La fonction renvoie alors la valeur de ce flot, qui pourra être augmenté le long du chemin de s à u, par les appels successifs qui ont mené à u. Concrètement pour pousser un flot de valeur val de u à t, on parcourt tous les voisins v de u dans le graphe par niveaux, et de façon récursive on tente de faire passer un flot maximum de v à t. La somme est alors le flot maximum qu'’on a pu pousser de u à t. 
9.7. FLOT MAXIMUM PAR DINIC - 135  Cette fonction dinic(u,val) détecte alors si plus aucun flot ne passe de u à t, donc si t devient inaccessible à partir de u. Dans ce cas elle enlève u du graphe par niveaux, tout simplement en lui indiquant un niveau fictif -1. Ainsi les appels suivants ne tenteront plus de passer un flot à travers w. Clairement au bout de O(n) itérations, s et t sont déconnectés, et on recalcule un nouveau graphe par niveaux, voir figure 9.5. Même si vous utilisez une liste d’adjacence pour le graphe, il est pratique d’utiliser des matrices pour la capacité résiduelle et le flot. On veillera bien à ce que chaque opération préserve la symétrie f(u, v) = —f(v, u).  def dinic(graph, capacity, source, target):  assert source != target add_reverse_arcs(graph, capacity) Q = deque() total = 0  n = len(graph) flow = [[0] # n for u in range(n)] # flot init. vide  while True: # répéter tant qu'on peut augmenter Q.appendleft(source) lev = [None] # n # construire niveaux, None = inaccessible lev[source] = 06 # par parcours BFS while Q: u = Q.pop()  for v in graph[u]: if lev[v] is None and capacity[u][v] > flow[u][v]: lev[v] = lev[u] + 1 Q.appendleft (v)  if lev[target] is None: # arrêt si puits inaccessible | return flow, total | # UB = borne supérieure | UB = sum(capacity[source][v] for v in graph[source]) - total total += _dinic_step(graph, capacity, lev, flow, source, target, UB)  def _dinic_step(graph, capacity, lev, flow, u, target, limit): if limit <= 0:  return 0 if u == target: É return limit val = 6  for v in graph[u]: residuel = capacity[u][v] - flow[u][v]  if lev[v] == lev[u] + 1 and residuel > 0: | z = min(limit, residuel) aug = _dinic_step(graph, capacity, lev, flow, v, target, z)  flow[u][v] += aug flow[v][u] -= aug val += aug limit -= aug if val == lev[u] = None # sommet non franchissable à enlever return val ; 
136 CHAPITRE 9. COUPLAGES ET FLOTS  Détails d'implémentation Le tableau lev contient le niveau d’un sommet dans le graphe par niveaux. Ce graphe est recalculé dès que le puits n’est plus accessible à partir de la source. La fonction dinic_step pousse le plus de flot de u au puits, sans dépasser la limite donnée. Pour cela elle pousse un maximum de flot à ses voisins v dans le graphe par niveaux, et cumule dans val la quantité de flot déjà poussée. Quand aucun flot n’a pu être envoyé, le sommet v peut être enlevé du graphe par niveaux, en posant son niveau à None.  0 1 3 4 [niveaux]  @ 4/4 —@M9ŸD  10/10 0/2 \66 0/5 6/10  GÉ p —2n @/ _— 4j4 - @4/9  10/10 0/2 \66 0/5 10/10  c0. 0  .… e  Frcure 9.5 — L'évolution du graphe par niveaux pendant le déroulement de l'algorithme de Dinic. Seuls les arcs d’un niveau vers le niveau suivant sont montrés, étiquetés par f/c avec le flot f et la capacité c. En hachuré les arcs saturés, donc non franchissables, en pointillés des arcs qui feront partie plus tard du graphe par niveaux. Après avoir augmenté le flot de 4 le long du chemin s—2 — 4— t, le sommet t devient déconnecté. Il faut donc reconstruire le graphe par niveaux, qui est présenté en dessous. 
9,8. s— t COUPE MINIMUM | 137  FIGURE 9.6 — Une s — t coupe minimum S de coût 3 + 2 + 2 + 1 = 8. Les arcs sortants sont dessinés avec des traits continus.  9.8 s— tcoupe minimum  Application Dans un pays ennemi, il existe de villes reliées par des routes. Chaque route nécessite un certain coût pour être détruite. Etant donné deux villes s et # on souhaite connaître le coût minimal pour déconnecter s de t.  Définition Une instance de ce problème se compose d’un graphe orienté G(V, A) avec deux sommets distincts s, t et un coût pour chaque arc c : A — R*.Unes—t-coupe est un ensemble S € V, qui contient s mais pas t. De manière équivalente la coupe S est parfois identifiée par les arcs sortants de S, donc les arcs (u,v) avecu € Setv € S, voir figure 9.6. La valeur de la coupe est le coût total de ces arcs. Le but est de trouver une coupe de coût minimale.  Lien avec le problème de flot maximum Identifions les capacités c d’un problème de flot maximum avec les coûts c d'un problème de coupe minimum. Tout flot doit traverser la coupe, donc la valeur de toute coupe est une borne supérieure pour la valeur de tout flot. Mais il y a plus fort :  Le théorème Max-FLow-MIn-CUT énonce que la valeur du flot maximum est égale à la valeur de la coupe minimum.  Ce théorème a été montré indépendamment en 1956 par Elias, Feinstein et Shan- non d’une part et par Ford et Fulkerson d’autre part. La preuve est la suite de plusieurs observations simples.  1. Pour un flot f la quantité de flot qui sort d’une coupe S, à savoir f(S) = Luesves f(U,v), est la même pour tout S. Tout simplement parce que pour tout 
138 CHAPITRE 9. COUPLAGES ET FLOTS  w é S,w # t on a (par (9.4) puis par (9.2))  f()= }, faur)= }, fano)+ }" ft0,0)  ueS,vés ueS vés v = }, fu7)+} fuw)+) f,0)+ } f(w,0) uESvéS,v+w ueS ves vés  2 Z fu,v)= f(SUw).  ueSUw, véSUw  2. Par (9.3) on a f(S) < c(S), c’est-à-dire que le flot qui sort de S n’est jamais plus grand que la valeur de S. Ceci prouve déjà une moitié du théorème, à savoir que la valeur du flot maximum est au plus la valeur de la coupe minimum.  3. Maintenant si pour un flot f donné, on a f(S) < c(S) pour toute coupe S, alors il existe un chemin augmentant. Pour cela tout simplement posons S = setP = 0. Puisque f(S) < c(S) il existe une arête (u, v) avec u € S,v & S, f(u,v) < c(u,v). Ajoutons (u, v) à P. Si v = t, alors P est un chemin augmentant, sinon on ajoute v à S et on recommence.  Algorithme Cette observation nous donne un algorithme pour résoudre le pro- blème de la coupe minimum. Il s’agit de calculer d’abord un flot maximum. Puis dans le graphe résiduel, on détermine l’ensemble S des sommets v atteignables de- puis s par des arcs de capacité résiduelle positive. Alors S ne contient pas t car l’existence d’un chemin augmentant contredirait l’optimalité du flot. Le caractère maximal de S fait que tout arc sortant de S est saturé par le flot, sa capacité résiduelle est zéro. Donc S est une coupe de valeur minimale.  9.9 s— t coupe minimum pour graphe planaire  Graphe planaire Le problème de la s — t coupe minimum peut être résolu plus efficacement dans le cas où le graphe est planaire! et que le plongement dans le plan est donné. Pour simplifier la présentation, nous supposons que le graphe est une grille planaire. Cette grille est composée de sommets reliés par des arêtes, qui décomposent le plan en cellules. Imaginons que la grille est rectangulaire, que la source est le sommet en bas à gauche et le puits le sommet en haut à droite.  Graphe dual Dans le graphe dual chaque cellule est un sommet, et il existe deux sommets supplémentaires s’ et t’. Le sommet s’ représente le bord gauche et haut de la grille et le sommet # le bord droite et bas. Dans ce nouveau graphe deux sommets sont reliés s’ils sont séparés par une arête du graphe primal. Le poids de ces deux arêtes est le même.  1. Un graphe est planaire s'il est possible de le dessiner dans le plan sans croisement. Formellement, une affectation des sommets à des points doit exister (plongement) telle que les arêtes (segments) ne se croisent que dans leurs extrémités. 
9.10. PROBLÈME DE TRANSPORT . 139  o. e e  FIGURE 9.7 — À gauche, le graphe primal. À droite, le graphe dual : un s’ — f- chemin de longueur minimale correspond à une s — t coupe minimum.  L’observation clé Touts’—t’ chemin dans le graphe dual de longueur w correspond à une s — à coupe dans le graphe primal de même valeur w et vice-versa.  Algorithme en O((|V| + |El)log|V|) Donc pour calculer une coupe minimum dans cette grille, il suffit de trouver un plus court chemin dans le graphe dual, avec l’algorithme de Dijkstra par exemple.  Variantes Un problème important consiste à trouver une coupe minimum qui dé- connecte le graphe, c’est-à-dire trouver la plus petite s — t coupe sur toute paire de sommets s, t. Ce problème peut être résolu par l’algorithme de Stoer-Wagner en temps O(IVI - JE] + |V/*log|V]), ce qui est plus intéressant que de résoudre les ©(|V[?) s — t coupes pour chaque paire de sommets s, t, voir [25].  9.10 Problème de transport  Une généralisation au problème de flot est le problème de transport, où il existe plusieurs sources et puits. Concrètement chaque sommet v a une valeur d, qui repré- sente alors une offre quand elle est positive et une demande quand elle est négative. L'instance doit satisfaire Y vev d = 0 pour avoir une solution. Le but est de trouver un flot qui transporte les quantités proposées aux demandeurs, donc un flot qui respecte les capacités et tel que pour chaque sommet v le flot entrant moins le flot sortant soit égal à d,. Dans ce contexte on parle alors de circulation plutôt que de flot. Ce problème se réduit facilement à un problème de flot, en ajoutant des sommets source et puits et en reliant la source à tous les sommets avec une offre, et en reliant tous les sommets avec une demande au puits. 
!  140 CHAPITRE 9. COUPLAGES ET FLOTS  Une autre variante associe à chaque sommet un coût par unité de flot transportée. Par exemple si un arc e a un flot f = 3 et un coût w, = 4, le coût engendré par le flot sur cet arc est 12. Le but est alors de trouver un flot qui minimise le coût total. Le flot peut être soit un flot maximum, soit un flot qui respecte toutes les demandes dans le cas d’un problème de transport. On parle alors de problème de transport à coût minimal. Pour ce problème, il existe à nouveau des algorithmes similaires à l’algorithme hongrois de Kuhn-Muntkres, voir [1]. Une première approche pourrait être de partir d’un flot maximum, puis de trouver et d'augmenter le flot le long de cycles à coût négatif.  9.11 Réduction entre couplages et flots  Il existe deux liens intéressants entre le problème de couplage maximum biparti et le problème de flot maximum.  De couplage à flot D’abord, si vous disposez d’un algorithme pour calculer un flot maximum dans un graphe, vous pouvez calculer un couplage maximum dans un graphe biparti G(U, V, E). Pour cela il faut construire un nouveau graphe G’(V", E") dont les sommets V" = UU VU ({s, t} contiennent deux nouveaux sommets s, t. Le sommet source s est relié à tous les sommets de U et tout sommet de V est relié au sommet puits t. De plus chaque arête (u,v) € E correspond à un arc (u, v) € F’. Tous les arcs dans G” sont de capacité unitaire. On représente ce graphe par niveaux. Le 1°" niveau contient s, le deuxième U, le troisième V et le dernier t. Par construction, un flot de valeur k dans G’ correspond à k chemins qui traversent chaque niveau, sans jamais revenir à un niveau précédent. En conséquence, les capacités étant unitaires, un sommet u € U ne peut être traversé que par au plus un chemin et il en est de même pour les sommets de V. Ainsi, les arêtes entre le niveau 2 et 3 traversées par le flot forment un couplage de taille k.  De flot à couplage D’un autre côté, si vous disposez d’un algorithme pour cal- culer un couplage maximum dans un graphe biparti, alors vous pouvez résoudre le transport lorsque les capacités sont unitaires. Soit G(V, E), (d.)vev Une instance du problème de transport. La première étape consiste à ajouter une source s et un puits t. Pour tout sommet v on ajoute max{0, -d,} arcs (s, v) et max{0, d,} arcs (v,t). Le résultat est un graphe G” avec éventuellement de multiples arcs entre deux sommets, c’est pourquoi on parle de multigraphe. L'instance initiale du problème de transport a une solution si et seulement si le nouveau multigraphe G’ a un s — t flot de valeur À = E prd,>0 do- La deuxième étape consiste à construire un graphe biparti H(V*, V7, E”) qui admet un couplage parfait si et seulement si G’ a un s — t flot de valeur À. Pour chaque arc de G” de la forme e = (s, v) on génère le sommet e* € V*. Pour chaque arc de G” de la forme e = (v, t) on génère le sommet e” € V-. Pour les autres arcs e de G’ on génère deux sommets e” € V- ete* € V* et on les relie par une arête. De plus, pour tous les arcs e, f tels que la destination de e coïncide avec l’origine de f, une arête (e*, f7) est générée. 
9.11. RÉDUCTION ENTRE COUPLAGES ET FLOTS 141  Considérons un couplage parfait dans H. Une arête du couplage de la forme (e7,e*) correspond à l’absence de flot traversant l’arc e de G’. Une arête du couplage de la forme (e*, f7) avec e # f correspond à une unité de flot traversant l'arc e puis l’arc f. Par construction, tous les arcs adjacents à la source ou au puits sont traversés par le flot. Le couplage correspond à un s — t flot de valeur A, comme illustré en figure 9.8.  d g  Ficure 9.8 - Réduction du problème de transport à capacités unitaires vers le problème de couplage biparti maximal. 
142 CHAPITRE 9., COUPLAGES ET FLOTS  9.12 Largeur d’un ordre partiel — Dilworth  Définition On vous donne un ordre partiel, c’est-à-dire un graphe orienté G(V, A) acyclique et transitif. Une chaîne dans G est un chemin orienté et par extension l’ensemble des sommets qu'’il couvre. Une antichaîne dans G est un ensemble S de sommets tel que qu'il n’existe pas u,v € S avec (u,v) € À. La largeur de l’ordre partiel est définie comme la taille de la plus grande antichaîne. Le problème consiste à calculer la largeur. Un lien important entre chaînes et antichaînes est donné par le théorème suivant.  Théorème de Dilworth (1950) : soit a la taille de la plus grande antichaîne. Soit b le plus petit nombre de chaînes dans une décomposition des som- mets en chaînes. Alors a = b.  Pour une antichaîne À et une décomposition en chaînes B, À n’intersecte chaque chaîne de B qu’en au plus un sommet. Mais chaque sommet v € À doit appartenir à un chemin de B, car B partitionne le graphe. Donc |A| < |B|. Le théorème de Dilworth dit que les optima de ces deux problèmes sont en fait égaux. Pour se convaincre, considérons le graphe biparti H(V-, V*, E) tel que pour chaque sommet v € V il existe un sommet v" € V- et v* € V* et que pour chaque arc (u, v) € À il existe l'arête (u”, v*) € E. Soit M un couplage maximum du graphe H. Par le théorème de Kônig (voir section 9.1 page 118), il existe un ensemble de sommets S contenant au moins une extrémité de chaque arête de H et |M| = |S|. À M correspond une partition de G en chemins B, composée des arêtes (u, v) telles que (u”,v*) € M. Tout chemin termine par un sommet v tel que 7” soit libre dans M, donc |B| = |V| — IM. À S correspond une antichaîne A, composée des sommets v tels queniv” niv* ne sont dans S. Comme chaque arête de H à au moins une extrémité dans S, aucun arc n’a une extrémité dans À, il s’agit donc bien d’une antichaîne. La taille de À est au moins |V|—|S| par construction. Donc |A| = |B|, mais comme la taille d’une antichaîne est une borne inférieure de la taille d’une partition en chaînes, nous avons égalité.  Algorithme en O(|V| - |El) Ce problème se réduit au problème de couplage maxi- mum, voir figure 9.9. 
9.12. LARGEUR D'UN ORDRE PARTIEL — DILWORTH 143  — Produire un graphe biparti H(V-, V*, E), avec V7, V* étant des copies de V, et (u7,v*) € E ssi (u,v) € À. — Calculer un couplage maximal biparti M dans H. — Compter le nombre de sommets non couplés dans U. C’est la taille de la plus grande antichaîne dans G, donc la largeur de l’ordre partiel G. — Considérer l’ensemble d’'arcs D dans G tel que (u, v) € D ssi (u,v) € M. Alors D est une décomposition de V en un nombre minimal de chaînes.  Frcure 9.9 — De gauche à droite : un ordre partiel G, le graphe biparti associé H avec une couverture de sommets S marquée en gris, un couplage maximum dans H, la décomposition en chaînes maximum dans G associée avec une antichaîne marquée en gris.  Application réservation de taxis Imaginons la situation suivante. Une société de taxis dispose de réservations de trajets pour le lendemain. La nuit, elle doit affecter des taxis de sa flotte aux réservations, et minimiser le nombre de taxis employés. Chaque réservation part d’un endroit précis de la ville à une certaine heure pour arriver à un autre endroit. On suppose que tous les temps de trajets sont connus. On peut alors décider si un même taxi peut honorer la réservation j après avoir honoré la réservation i. Dans ce cas on note i < j, et la relation < est un ordre partiel sur les réservations données. La réponse au problème est simplement la largeur de cet ordre. 
144 CHAPITRE 9. COUPLAGES ET FLOTS  Généralisation Si le graphe est pondéré, on peut chercher une décomposition mi- nimale en chaînes qui de plus minimise le poids total des arcs. Ce problème se résout en temps O(|V*) par réduction au problème de couplage parfait à coût minimal dans un graphe biparti.  Détails d’implémentation Notre implémentation renvoie un tableau p codant une partition optimale, où p[u] est l’indice de la chaîne contenant le sommet u, les chaînes étant numérotées à partir de 0. La fonction reçoit en entrée une matrice carrée M qui indique pour chaque couple de sommets u, v le coût de l’arc (u,v) ou None si l’arc n’existe pas. Cette implémentation suppose que les sommets sont dans l’ordre topologique, donc si l’arc (u, v) existe alors u < v.  def dilworth(graph) : | n = len(graph) match = max_bipartite_matching(graph) # couplage maximum  part = [None] # n # partition en chaînes nb_chains = 0 | for v in range(n - 1, -1, -1): # dans l’ordre topologique inverse if part[v] is None: # début d’une chaîne | u=v while u is not None: # suivre la chaîne | part[u] = nb_chains # marquer  u = match[u] nb_chains += 1 return part  L P Ts S 
10. Arbres  Les arbres sont des structures combinatoires qui apparaissent naturellement lorsque les objets considérés sont basés sur une structure de composition. Ceci est le cas par exemple des classifications, des hiérarchies, des généalogies. La structure des arbres demande en général des traitements récursifs, et la clé pour un algorithme est de trouver la bonne méthode d’exploration. Nous passons en revue certains pro- blèmes classiques sur des arbres. Formellement, un arbre est un graphe connexe et acyclique. Un de ses sommets peut être désigné comme la racine, et dans ce cas on parle généralement d'arbre enraciné. Cette racine donne aux arcs une orientation de père à fils. En partant d’un sommet et en remontant les liens, on aboutit à la racine. Les sommets sans fils sont appelés les feuilles. Un arbre à n sommets est composé d’exactement n — 1 arêtes. Pour s'en convaincre, il suffit de remarquer que si l’on enlève d’un arbre de façon répétée une feuille ainsi que l’arête adjacente, on finit par aboutir à un sommet isolé, c'est-à-dire un arbre à 1 sommet et 0 arête. Il existe de nombreuses structures de données dynamiques basées sur des arbres, telles que les arbres binaires de recherche rouge et noir, ou les arbres d'’intervalles. Ces structures font intervenir des opérations de rééquilibrage sur les arbres afin de garantir que les opérations s’effectuent en temps logarithmique. En revanche, dans le cadre des problèmes issus des concours de programmation, l’entrée est donnée en une seule fois, et il est alors parfois possible de se passer de ces opérations, en construisant directement des structures équilibrées. On peut représenter un arbre essentiellement de deux manières. La première est la représentation classique des graphes, par liste d’adjacence, qui ne distingue aucun sommet particulier comme racine de l’arbre. Une autre représentation aussi couram- ment utilisée est celle par table de prédécesseurs. Pour un arbre enraciné (souvent au sommet 0), chaque sommet hormis la racine a un unique prédécesseur, codé dans une table. Suivant l'algorithme considéré et le type de problème, l’une ou l'autre représentation conviendra au mieux, et passer de l’une à l’autre se fait facilement en temps linéaire. 
146 CHAPITRE 10. ARBRES  \ def tree_prec_to_adj(prec, root=0): Ï n = len(prec) graph = [[prec[u]] for u in range(n)] # ajouter les prédécesseurs graph[root] = [] for u in range(n): # ajouter les descendants if u != root: graph[prec [u]].append (u) return graph  def tree_adj_to_prec(graph, root=6): n = len(graph) prec = [None] * len(graph)  prec[root] = root # marquer pour ne pas revisiter la racine | to_visit = [root] Ï while to_visit: # parcours DFS  node = to_visit.pop() for neighbor in graph[node]: if prec[neighbor] is None: prec[neighbor] = node to_visit.append(neighbor) prec[root] = None # mettre la marque standard pour la racine return prec  10.1 Code de Huffman  Définition Un code binaire pour un alphabet E est une fonction c : E — (0,1} telle qu’aucun mot de code c(a) ne soit préfixe d'un autre mot c(b) pour a, b € X. Ce code appliqué à chacune des lettres transforme un mot de E* en un mot de {0,1}", et la propriété sur les préfixes permet de décoder le mot original sans ambiguïté. En général, on souhaite que le codage soit !e plus court possible et pour cela, on aimerait coder des lettres plus fréquentes par des mots de code plus courts. Formellement, étant donné une fonction de fréquence f : E — R*, on cherche un code qui minimise  le coût $ f(a) -leta)l  aez  Algorithme en O(nlogn) où n est la taille de l’alphabet. Un code de Huffman est vu comme un arbre binaire, dont les feuilles sont les lettres de l’alphabet et chaque nœud est étiqueté par la somme des fréquences des lettres aux feuilles du sous-arbre enraciné en ce nœud. Pour le construire, on part d’une forêt, où chaque lettre est un arbre à un seul sommet étiqueté par sa fréquence. Puis, tant qu'il y a plusieurs arbres, on réunit les deux arbres de fréquences minimales, et on étiquette la nouvelle racine par la somme des fréquences des deux sous-arbres, voir figure 10.1. Par un argument d’échange, on peut montrer qu’un code ainsi produit est optimal. Pour manipuler les arbres, on les place dans une structure de données qui permet efficacement d’ajouter des éléments et de retirer l'élément minimal : une file de priorité. Ces opérations ont un coût logarithmique en le nombre d’objets dans la 
10.1. CODE DE HUFFMAN ; 147  structure. On l'implémente en général par un tas (heap en anglais). En Python, cette structure se trouve dans le module heapq. Les éléments stockés dans la file de priorité sont des couples (fa, A) où À est un arbre binaire et f4 la fréquence totale des lettres stockées dans A. Un arbre est codé de deux manières. Une lettre a représente un arbre composé de l’unique feuille (et racine) a. Un arbre composé d’un sous-arbre gauche € et sous-arbre droit r est représenté par le couple (€, r), qui doit être une liste pour éviter les copies.  def huffman(freq): ; u- for a in freq: heappush(h, (freaq[a], a)) Ï while len(h) > 1: (£1, 1) = heappop(h) | (fr, r) = heappop(h) Ï heappush(h, Cf1 + fr, [1, r1l)) | code = {} | extract(code, h[6][1]) return code  \ def extract(code, tree, prefix=""): | if isinstance(tree, list): Ï 1, r = tree Î extract(code, 1, prefix + "0") extract(code, r, prefix + "1") else: code[tree] = prefix 
148 CHAPITRE 10. ARBRES  01 A V a4Ÿ 9) (15) (31) (25) 20 NNN O Lettre Fréquence Code A B C D E enentrée … ensortie .................................................... A 7 00 A B 7 01 Qÿ @ 7 10 0 1 D 7 11 — A 40 1 @ (24 » 01 Ë e 2 001 e }\ ; È. | 000 9) 15 (o1) 25 A B C D “ 03 E O 1 c 9) (45 @ 31) Frcure 10.1 — Construction d’un code q de Huffman. Chaque nœud est étiqueté à H B6 par la somme des fréquences des lettres """"""""""""""""""""""""""" aux feuilles du sous-arbre enraciné en 100 ce nœud. En dessous, deux exemples ë j de codes de Huffman produits sur deux 44 entrées différentes. < j @ À e 20 @ @5) @ E 1 D C 
10.2. ANCÊTRE COMMUN LE PLUS PROCHE 149  10.2 Ancêtre commun le plus proche  F  tA 1  e /\ /ë /\ /\  H  entrée: B, G sortie: ÀA  Définition Étant donné un arbre à n nœuds, on souhaite pouvoir répondre en temps O(log n) à des requêtes qui demandent pour deux sommets u,v donnés l’ancêtre commun le plus proche dans l’arbre (lowest common ancestor ou LCA en anglais). Il s'agit de l'unique sommet u” tel que u et v soient contenus dans le sous-arbre de u’ et aucun descendant direct de v’ n’ait cette propriété.  Structure en O(logn) par requête L'idée est d’enrichir chaque nœud u avec une information de niveau (level) et des références vers des ancêtres, où anc[k,u] est un ancêtre de u de niveau level[u]-2* s’il existe et -1 sinon. Ainsi, on peut utiliser ces pointeurs pour rapidement remonter aux ancêtres. Considérons la requête LCA(u,v) : « quel est l'ancêtre de u et v le plus proche ? », pour laquelle sans perte de généralité on peut supposer level[u] < level[v]. D'abord, on va choisir l’ancêtre de v du même niveau que u. Puis itérativement pour chaque k de log, n à O, si anc[k,u] # anc[k,v] alors on remplace u, v par leurs ancêtres anc[k,u], anc[k,v]. À la fin, u = v et on a trouvé l’ancêtre commun.  Détail d'implémentation Nous supposons que l’arbre est donné sous forme d’un tableau prec, qui pour chacun des noeuds de l’arbre u € {0,1,...,n — 1}, indique le noeud père prec[u]. L'hypothèse est faite que le père a un indice moindre que le fils, et que la racine est le sommet 0. 
150 CHAPITRE 10. ARBRES  \class LowestCommonAncestorShortcuts: def __init__(self, prec): n = len(prec) self.level = [None] # n # construit les niveaux self.level[0] = 6 for u in range(1i, n): self.level[u] = 1 + self.level[prec[u]] depth = log2ceil ((max(self.level[u] for u in range(n))) + 1 self.anc = [[6] * n for _ in range(depth)] for u in range(n): self.anc[{0][u] = prec[u] for k in range(1, depth): for u in range(n): self.anc[k][u] = self.anc[k - 1][self.anc[k - 1][u]]  def query(self, u, v): # -- supposer que v n’est pas plus haut que u dans l’arbre if self.level[u] > self.level [v]: u, v=v, u # -- ramener v au même niveau que u depth = len(self.anc) for k in range(depth-1, -1, -1): if self.level[u] <= self.level[v] - (1 << k): v = self.anc[k][v]  assert self.level[u] == self.level [v] | if u == v: | return u { # -- remonter jusqu’à l’ancêtre commun le plus proche  for k in range(depth-1, -1, -1): if self.anc[k][u] != self.anc[k][v]: u = self.anc[k][u] v = self.anc[k][v] assert self.anc[0][u] == self.anc[0][v] return self.anc[6][u]  Solution alternative par réduction au minimum dans une plage Considérons un parcours en profondeur de l’arbre, comme en figure 10.2. Supposons pour simplifier que les sommets sont numérotés de telle sorte que pour tout sommet u, son numéro est plus grand que le numéro de son père. Notons dans un tableau t la trace de ce parcours, en y inscrivant le sommet v au moment de sa première visite, de sa dernière visite et entre chaque descente récursive vers un fils. On note par f[u] le temps de fin de traitement du sommet u. Maintenant le tableau t contient entre f[u] et f[v] tous les sommets intermédiaires visités par le parcours entre u et v. Le sommet minimal dans cet intervalle est l’antécédent commun le plus bas de u et v. Il suffit alors de produire en temps linéaire la trace t du parcours en profondeur de l’arbre et d’utiliser un arbre de segments pour répondre aux requêtes, voir section 4.5 page 61. La construction de la structure prend un temps O(nlog n) et la complexité d’une requête est O(log n).  Détails d’implémentation Cette implémentation reçoit le graphe sous forme de liste d’adjacence. Elle ne fait aucune hypothèse quant à la numérotation des sommets. En conséquence, la trace dfs_trace ne contient pas simplement les sommets mais le 
10.2. ANCÊTRE COMMUN LE PLUS-PROCHE  151  couple (profondeur, sommet). Comme l'entrée pourrait être grande, le parcours DFS est implémenté de manière itérative avec une pile to_visit. Le tableau next indique pour chaque sommet combien de descendants ont déjà été explorés.  1|2|3|4|3/2/5|6/5/7|5/8/5/2/1/9/1  Ficure 10.2 — Réduction du problème de l’antécédent commun le plus bas à celui du minimum dans une plage sur la trace d’un parcours en profondeur.  5c1ass LowestCommonAncestorRMQ :  def  def  __init__(self, graph): n = len(graph) dfs_trace = [] self.last = [None] # n to_visit = [(0, O, None)] next = [0] # n while to_visit: level, node, father = to_visit[-1] self.last[node] = len(dfs_trace) dfs_trace.append((level ,, node)) if next[node] < len(graph[node]) and \ graph[node] [next[node]] == father: next[node] += 1 if next[node] == len(graph[node]): to_visit.pop() else: neighbor = graph[node] [next[node]] next[node] += 1 to_visit.append((level + 1, neighbor, node)) self.rmq = RangeMinQuery(dfs_trace, (float(’inf’), None))  query(self, u, v): lu = self.last[u] lv = self.last[v] if lu > lv: lu, lv = lv, lu return self.rmg.range_min(lu, lv + 1)[1]  # sommet 0 est la racine 
152 CHAPITRE 10. ARBRES  Variante Avec cette structure, on peut donc aussi déterminer la distance entre deux nœuds de l’arbre, car le plus court chemin passe par l'antécédent commun le plus proche.  10.3 Plus long chemin dans un arbre  Définition Étant donné un arbre, on cherche le plus long chemin dans cet arbre. Complexité linéaire.  Algorithme par programmation dynamique Comme beaucoup de problèmes sur des arbres, on peut appliquer la programmation dynamique en raisonnant sur les sous-arbres. On fixe une racine, ce qui a pour effet d’orienter les arêtes de l’arbre. Pour chaque sommet v, considérons le sous-arbre avec v comme racine. On note b[v] la longueur du plus long chemin dans ce sous-arbre terminant en v et t[v] la longueur du plus long chemin dans ce sous-arbre sans restriction. On appelle b[v] aussi la profondeur du sous-arbre enraciné en v. Si v n’a pas de fils, b[v] = t[v] = 0. Sinon on a les relations suivantes.  b[v] = 1 + maxb[u] sur les fils u de v t[v] = max{maxt[v;], maxb[u1] +2 + b[u,]} — sur les éventuels fils 4,, u2 de v  Le programme peut se passer de tests sur le nombre de fils en utilisant -1 comme valeur par défaut. Notez qu'il n’est pas nécessaire de trier les fils d'un nœud pour obtenir les deux fils maximisant leur b-valeur.  Piège S'il y a de l’ordre du million de sommets dans l’arbre, un parcours en pro- fondeur (DFS) en Python est hors d’atteinte à cause de la limite sur la pile des appels. Il faut alors effectuer un DFS avec une pile explicite.  Observation Soit 7 un sommet arbitraire de l’arbre et 4 un sommet de distance maximale de r. Alors il existe un plus long chemin dans l’arbre d’extrémité u. Pour s’en convaincre, soit un plus long chemin avec les extrémités v,, v,. Soit u" un sommet sur le chemin de r à u et v" un sommet sur le chemin de , à ,, qui minimise la distance de u’ à v’. Si les deux chemins se croisent, on peut choisir pour u’,v’ un sommet arbitraire dans l’intersection, voir figure 10.3. Notons par d la distance dans l’arbre. Par le choix de u on a  d(u',u) > d(u’,v") + d(v",v1) et par l’optimalité du chemin de v, à v, on a d(v1,v") > d(o",u”) + d(u', u),  ce qui implique d(u’,v”) = 0 et d(v',v1) = d(v', u) et donc le chemin de vz à u est aussi un plus long chemin dans l’arbre. 
10.4. ARBRE COUVRANT DE POIDS MINIMAL - KRUSKAL 153  Ficure 10.3 — Un argument d’échange permet de montrer que si u est un sommet de distance maximale de r alors il existe un plus long che- min partant de u.  Algorithme par parcours en profondeur L'observation précédente implique l’exis- tence d’un algorithme alternatif. Par un parcours en profondeur (DFS) on peut déter- miner un sommet de distance maximale d’un sommet donné. Donc on peut choisir un sommet arbitraire r, déterminer un sommet v, de distance maximale de v;, puis à nouveau déterminer un sommet v, de distance maximale de v,. Le chemin de v, à v2 est un chemin de longueur maximale.  Variante On veut supprimer le moins d’arêtes possible dans un arbre afin que dans les arbres résultants aucun chemin ne dépasse la longueur R. Pour résoudre ce problème, il suffit de supprimer les arêtes critiques déterminées pendant la résolution du programme dynamique ci-haut. Considérons le traitement du sommet v avec ses fils #1,.., 44 ordonnés tels que b[u1] < … < b[u4]. On doit supprimer l’arête (v, ug) si b[ug] + 1 > R ou que b[ug_1] + 2 + b[u;] > R, puis décrémenter d et recommencer le test.  10.4 Arbre couvrant de poids minimal - Kruskal  Définition Étant donné un graphe non orienté connexe, on souhaite trouver un ensemble d’arêtes tel que tout couple de sommets soit relié à l’aide de ces arêtes. Les arêtes sont pondérées positivement et on souhaite trouver un tel ensemble de poids total minimal. Notons qu’un tel ensemble est acyclique — et donc un arbre — car la suppression d’une arête d’un cycle préserve la connexité. On cherche donc ce qu’on appelle un arbre couvrant de poids minimal, voir figure 10.4.  Application Les arêtes du graphe sont données avec un coût (ou poids) w, et il faut acheter à moindres frais des arêtes pour rendre le graphe connexe. Donc il faut trouver un ensemble À C E tel que G(V, A) soit connexe et E…a W(e) soit minimum.  Algorithme en O(JF|log|F|) L'algorithme de Kruskal résout ce problème de manière gloutonne en parcourant les arêtes (u, v) par poids croissant, et en sélectionnant chaque arête (u,v) si cela ne forme pas un cycle. L'optimalité de l’algorithme peut être montrée par un argument d’échange. Soit A la solution produite par l’algorithme et B une solution quelconque. Soit e la première arête choisie par l’algorithme mais 
154 CHAPITRE 10. ARBRES  Ficure 10.4 — Arbre couvrant de poids minimal dans un graphe com- plet à 256 sommets où le poids de chaque arête est la distance eucli- dienne qui sépare ses extrémités.  qui ne fait pas partie de B. Alors B U {e} contient un cycle C. Par le choix de e, toutes les arêtes du cycle C ont un poids au moins aussi grand que e, et donc remplacer une de ces arêtes par e dans B préserve la connexité de B et n'augmente pas le coût, tout en diminuant une certaine distance ! entre À et B. En choisissant B comme la solution optimale la plus proche de À, on arrive à une preuve par contradiction.  Détails d’implémentation Pour parcourir les arêtes par poids croissant, on crée une liste de couples comportant poids et arête. Cette liste est triée selon l’ordre lexicographique sur les couples, puis parcourue. Afin de maintenir la forêt d’arbres et de pouvoir détecter efficacement si l’ajout d’une arête forme un cycle, on utilise une structure union-find, voir section 1.5.5 page 23.  def kruskal(graph, weight): uf = UnionFind(len(graph)) edges = [(] for u in range(len(graph)): for v in graph[u]: edges.append((weight[u][v], u, v)) edges.sort () mst = [] for w, u, v in edges: if uf.union(u, v): mst.append((u, v)) return mst  Algorithme de Prim Il existe un autre algorithme pour ce problème, l'algorithme de Prim. Son fonctionnement est similaire à l’algorithme de Dijkstra. Il maintient pour un ensemble de sommets S une file de priorité Q constituée de toutes les arêtes sortantes de S. Initialement, S contient un unique sommet arbitraire u. Puis tant que S ne contient pas tous les sommets, une arête (u, v) de poids minimale est extraite de Q avec u € S, v & S. Le sommet u est alors ajouté à S et Q est mis à jour. La complexité de cet algorithme est également O(JE|log |F]).  1. Comme distance entre À et B on choisira |A\B| + |B\A|. 
_ 11. Ensembles  Ce chapitre complète celui sur les séquences. Il illustre qu’une même méthode, celle de la programmation dynamique, permet de résoudre une grande variété de problèmes. Nous commençons par présenter deux problèmes classiques, ceux du sac à dos et du rendu de monnaie.  11.1 Sac à dos  Définition On se donne n objets de poids respectifs po, … , pn-1 et de valeurs respec- tives vo,..., Un-1 ainsi qu'un entier C qui est la capacité du sac et on se demande com- ment obtenir un sous-ensemble d’objets de valeur totale maximale, dont la somme des poids ne dépasse pas C. Ce problème est NP-difficile.  Observation clé Notons pouri e {0,...,n — 1} etc € {0,..., C} par Optli][c] la plus grande valeur qu’on puisse obtenir avec les objets d'indice 0 à i sans dépasser en poids la capacité c. Pour le cas de base / = 0, nous avons Opt{[0][c] = 0 si po > c et Opt[0][c] = v; sinon. Pour des valeurs de ; plus grandes ; =1,.,n —1,au plus deux choix possibles se présentent pour l’objet d’indice ; : soit on le prend, soit on ne le prend pas. Dans le premier cas la capacité se voit réduite de pi. Ainsi on a la relation  Optli —1][c—p;]+w; cas où l’on prend l’objet, Optli][c] = max à condition que c = c; Opt{i — 1][c] cas où l’on ne prend pas l’objet.  capacité FIGURE 11.1 — Une représentation du tableau Opt. Le calcul de chaque case requiert au plus deux cases de la ligne qui la précède, dont celle juste au-dessus. Il s'agit d’un cas particulier de problème de plus long chemin dans une grille, décrite en section 3.1 nombre d’objets page S1.  Algorithme en O(nC) On dit qu’un algorithme d'une telle complexité est pseudopo- lynomial. Une matrice booléenne Sel est maintenu en parallèle avec la matrice Opt du programme dynamique. Elle permet de se souvenir du choix qui a mené aux valeurs 
[ {  156 CHAPITRE 11. ENSEMBLES  écrites dans Opt. Une fois ces matrices remplies en suivant la formule de récurrence décrite ci-haut, un parcours des éléments en ordre inverse permet d’extraire de la matrice Sel l’ensemble des éléments ayant produit la valeur optimale.  def knapsack(p, v, cmax): | n = len(p) Opt [[6] * (cmax + 1) for _ in range(n + 1)] Sel [[False] # (cmax + 1) for _ in range(n + 1)] # --- cas de base for cap in range(p[0], cmax + 1): Opt[6][cap] = v[6] Sel [6] [cap] True # --- cas d’induction | for i in range(l, n): | for cap in range(cmax + 1): if cap >= pLi] and Opt[i-1][cap - p[i]] + v[i] > Opt[i-1][cap]: Opt[i][cap] Opt[i-1][cap - p[i]] + v[i] Sel[i][cap] True else: Opt[i][cap]  Wn  Opt[i-1][cap]  Sel[i][cap] False | # ‘ --- lecture solution Ï cap = cmax sol = [] | for i in range(n-1, -1, -1): | if Sel[i][cap]:  sol.append(i) cap -= p[i] return (Opt[n - 1][cmax], sol)  11.2 Rendu de monnaie  À présent, on souhaite obtenir un montant R avec des pièces de monnaie ou des billets qui valent respectivement xo,..., x,-1 centimes. Le problème consiste donc à déterminer s’il existe une combinaison linéaire positive de xo,..., *,-1 qui vaut R. Vous rigolez, mais en Birmanie (aujourd’hui Myanmar), il y avait des billets de 15, 25, 35, 45, 75 et de 90 kyats (voir figure 11.2). Pour ce problème, une valeur x; peut contribuer plusieurs fois à une somme.  | def coin_change(zx, R): b = [False] * (R + 1) b[0] = True for xi in x: for s in range(xi, R + 1): b[s] |= b[s - xi] return b[R]  Variante Dans le cas où il existe une solution, on peut s’intéresser à celle qui utilise un nombre minimal de pièces.  Observation On pourrait penser qu'il suffirait de considérer les pièces par valeur décroissante et de donner à tout moment celle qui ne dépasse pas le montant restant. 
11.3. SOUS-ENSEMBLE DE VALEUR TOTALE DONNÉE 157  Ficure 11.2 — Un billet de 45 kyats.  Cela réalise un nombre minimal de pièces pour le système monétaire européen, mais si on considère le système suivant : 1, 3, 4 et 10 et que l’on souhaite rendre la somme de 6, cette approche gloutonne résulte en une solution de 3 pièces composée de 4 + 1+1 tandis que l’unique solution optimale est 3 + 3.  Algorithme en O(nR) Soit A[i][m] le nombre minimal de pièces pour rendre un montant 0 < m < R parmi le système monétaire xo,,x;-1 et A[i][m] = œ s’il n’y a pas de solution. On dérive une relation de récurrence similaire à celle qu’on obtient pour le sac à dos : pour tout montant m, A[0][m] vaut m/xo si xo divise m et c sinon. Pour:=1,...,n —1 onalla relation :  Alillm—x;] +1 cas où l’on choisit la pièce A[i][m] = max à condition que m > x; A[i — 1][] cas où l’on ne choisit pas la pièce.  11.3 Sous-ensemble de valeur totale donnée  c f = — ]  input output  Définition Étant donné n entiers positifs xo,...,Xn-1, ON Veut savoir s’il en existe un sous-ensemble dont la somme vaut une valeur R donnée. Ce problème est NP- difficile.  Algorithmeen O(nR) On maintient un tableau de booléens qui indique pour chaque indice / et chaque 0 < s < R s’il existe un sous-ensemble des entiers xo, x1, , X; dont la somme vaut s. Initialement ce tableau n’est vrai qu’à l’indice O, pour l’ensemble vide. Puis pour chaque ; € {0,...,n — 1} et tout s € {0,..., R}, on peut produire un sous-ensemble de somme s avec les entiers xp,..., x;, Si et seulement s’il existe un sous-ensemble de Xo,-..,X;-1 de somme s ou s — x;. Notez l’ordre de la boucle sur s dans l’implémentation. 
158 CHAPITRE 11. ENSEMBLES  \ def subset_sum(x, R): b = [False] #* (R + 1) | b[60] = True for xi in z: for s in range(R, xi - 1, -1): b[s] |= b[s - xi] | return b[R] |  Algorithme en O(2""/71) Intéressant lorsque R est grand et n petit. On divise l’entrée X = (xo,...,Xn-1} en deux parties disjointes A et B de taille au plus [n/2]. Si Sa (resp. Sg) est l’ensemble des sommes de chaque sous-ensemble de À (resp. B), on va construire l’ensemble Y = S, et l’ensemble Z = R — 55 contenant les valeurs de la forme R — v où v décrit Sz. Il suffit alors de tester si Y et Z ont une intersection non vide.  Wfléfrbart_sum(x, i=0): ! if i == len(x):  yield 0 else: for s in part_sum(x, i + 1): yield s  yield s + x[i]  \def subset_sum(x, R):  k = len(x) // 2 # diviser l’'entrée Y = [v for v in part_sum(x[:k])] Z = [R - v for v in part_sum(x[k:])] | Y.sort() # test d’intersection Y avec Z Z.sortO | i =0 ] j =0 | while i < len(Y) and j < len(2Z): ;  if Y[i] == ZL[jl: return True elif Y[i] < Z[j]: # incrémenter l’indice du plus petit élément]  i+l | else: Ï j+1l Ï  return False |  Variante : partitionner en deux sous-ensembles le plus équitablement possible Étant donné xo,…,Xn-1, il faut produire S € {0,...,n —1} tel que | L ;es Xi — Lies Xi| Soit le plus petit possible. L'algorithme esten O(n x;). Il faut procéder comme pour la somme partielle, puis chercher dans le tableau de booléens b un indice s avec b[s] vrai le plus proche possible de F x;/2. Donc pour tout a = 0,1,2,.. et d = +1, —1, considérer b[} x;/2+a - d]. 
11.4. k-SOMME ‘ 159  11.4 k-somme  Définition Étant donné n entiers xo,..,xy-1, ON souhaite savoir s’il est possible d’en sélectionner k dont la somme vaut zéro.  Application Pour k = 3, ce problème est important en géométrie discrète, dans le sens où beaucoup de problèmes classiques se réduisent vers le problème 3-somme. Par exemple, on vous donne n triangles et on souhaite savoir s’ils recouvrent entiè- rement un autre triangle donné. Ou encore, on vous donne n points et on souhaite savoir s’il existe une droite qui traverse au moins 3 de ces points. Pour tous ces pro- blèmes, il existe des algorithmes en O(n°), et on conjecture que ceci est optimal [10]. Pour des valeurs plus grandes de k, ce problème est important en cryptographie.  Algorithme en O(n*") Tout d’abord, une observation sur le cas k = 2. Il s’agit de décider s'il existe / # j avec x; = —x;. Si x est trié, un double parcours — comme pour la fusion de listes triées — permet de résoudre le problème (voir section 4.1 page 59). Sinon on peut stocker l’entrée dans une table de hachage puis chercher pour chaque X; si —x; se trouve dans cette table. Pour k = 3, nous proposons un algorithme en temps O(n*). On commence par trier x. Puis pour chaque r;, il s'agit de décider si les listes x + x; et —x ont un élément en commun, car un tel élément sera de la forme x; + x; et —xz avec x; + x; + X = 0. Cette approche se généralise pour des valeurs de k plus grandes, mais est moins performante que l’algorithme suivant.  Algorithme en O(n'*/71) On construit un multi-ensemble A d’entiers obtenus en sommant exactement |k/2] entiers de l’entrée. De même, on construit un multi- ensemble B obtenu en sommant exactement [k/2] entiers de l’entrée. À présent, il suffit de tester si les ensembles A et R — B ont une intersection non vide. Pour cela on trie À et B, puis on procède comme pour la fusion de deux listes triées par un parcours conjoint sur les deux listes. La complexité est O(n!*/?1),  Détails d’implémentation Pour éviter de prendre plusieurs fois les mêmes indices, on stocke dans À, B non seulement les sommes mais aussi les couples composés de chaque somme et des indices qui l’ont réalisée. Ainsi, on peut vérifier pour chaque couple dans À et B de somme R si les indices sont disjoints. 
12. Points et polygones  L'objet principal au cœur des problèmes géométriques est le point. Il représente une position dans l’espace, et de nombreux problèmes classiques concernant les points dans le plan sont présentés dans ce chapitre. Nous allons naturellement représenter des points par le couple de leur coordon- nées. Une opération de base importante est le test d’orientation, voir figure 12.1. Étant donné trois pomts a,b,c on souhaite savoir si les trois points sont alignés sur une même droite, ou si une marche a — b — c effectue un changement de direction vers la gauche ou vers la droite.  b  Ficure 12.1 — Le test left_turn(a,b,c) renvoie Vrai sur ces points.  [def left_turn(a, b, ©: return (a[0] - c[9]) * (b[l] - c[1]) - (a[1] - c[1]) * Cb[0] - c[®]) >o  Dans le cas où les points ont des coordonnées non entières, il est conseillé pour se protéger des erreurs d’arrondi de ne pas comparer avec zéro mais avec un seuil de tolérance de 107 par exemple. 
162 CHAPITRE 12. POINTS ET POLYGONES  12.1 Enveloppe convexe  Entrée Sortie  Définition Étant donné un ensemble de n points, il faut produire un polygone convexe formé à partir d’un sous-ensemble de ces points tel que les points restants soient contenus dans le polygone. La solution est aussi le polygone de plus petit périmètre contenant tous les points.  Borne inférieure surla complexité Il n’est pas possible dans le cas général de calcu- ler l'enveloppe convexe en temps o(n log n) !. Pour s’en convaincre, soit une séquence de n nombres a,, … , a,. Le calcul de l’enveloppe convexe des points (a;, aÎ), … (@n,A2) les renverra dans un ordre correspondant au tri des nombres a1,...,ay. Donc si on pouvait faire ce calcul en o(n log ) opérations, cela donnerait un algorithme de tri de même complexité.  Algorithme en O(nlogn) L'algorithme généralement présenté pour ce problème est le balayage de Graham. Mais nous préférons présenter une variante, l'algorithme d'Andrew, qui ne traite pas les points suivant leur angle autour d’un point de repère, mais en fonction de leur coordonnée x. Il à l’avantage de ne pas faire intervenir des calculs d’angles qui pourraient introduire des erreurs de précision. Nous décrivons seulement comment obtenir la partie du haut de l’enveloppe convexe. Les points sont parcourus dans l’ordre croissant de leur coordonnée x, et dans un tableau top on maintient l'enveloppe convexe des points déjà traités. Chaque nouveau point p est ajouté à top, puis tant que l’avant-dernier point de top rend la séquence non convexe, il est enlevé.  Détails d’implémentation La partie basse de l'enveloppe bot est obtenue de la même manière. Le résultat est la concaténation des deux listes, en inversant l’ordre de la liste top pour obtenir les points de l’enveloppe dans l’ordre normal, le sens contraire des aiguilles d’une montre. Notez que les premiers et derniers éléments des listes sont identiques, et seraient alors en double dans le résultat de la concaténation. Il est alors important d’enlever ces éléments en trop. Pour faciliter l’écriture dans le code présenté, le point p n’est ajouté aux listes top et bot qu’après la suppression des éléments ayant rendu la séquence non convexe.  1. Il s’agit bien du petit o de la notation de Landau. 
12.2. MESURES D'UN POLYGONE 163  ädef andrew(s): | S.sort) | top = [] | bot = [] Ï for p in S: | while len(top) >= 2 and not left_turn(p, top[-1], top[-2]): | top.pop() | top.append(p) while len(bot) >= 2 and not left_turn(bot[-2], bot[-1], p): bot .pop() bot.append(p) return bot[:-1] + top[:06:-1]  12.2 Mesures d’un polygone  Étant donné un polygone simple! p sous la forme d'une liste de n points en orien- tation normale”, on peut être amené à effectuer plusieurs mesures, voir figure 12.2.  Calcul de la surface en temps linéaire On peut calculer la surface À via la formule  —  n-  A= (XiYisr — Xix1Vi),  MIH Il S  où l’indice ; + 1 est pris modulo n. Le i-ième terme désigne la surface signée du triangle (0, Pi, P;+1), dont le signe dépend de l’orientation du triangle. Chaque terme permet de se ramener au calcul de l’aire d’un polygone ayant un point de moins, ainsi l’aire du polygone peut être exprimée comme une séquence d’additions et de soustractions d’aires de triangles.  | def area(p): | A=0 | for i in range(len(p)): A += pli - 1][0] * p[i][1] - pLi][6] * p[i - 1][1] return ÀA / 2.  Calcul du nombre de points entiers surlecontour Pour simplifier, nous supposons que les points du polygone ont des coordonnées entières. Dans ce cas, la réponse se détermine en sommant pour chaque segment [a, b] de p, le nombre de points entiers entre a et b, excluant a pour ne pas compter de point en double. Si x est la valeur absolue de la différence des abscisses de a et b et y l’équivalent pour les ordonnées, alors ce nombre vaut y six=0 x siy=0 le PGCD dexety sinon. 
164 CHAPITRE 12. POINTS ET POLYGONES  — Ficure 12.2 — Le nombre de points entiers sur le contour (en noir) est 4, le nombre de points entiers intérieurs (en blanc) est 4 et la surface est 4 + 4/2—1 =5.  J  Calcul du nombre de points entiers intérieurs Ce nombre est obtenu par le théo- rème de Pick, qui relie la surface À d’un polygone, le nombre de points intérieurs n; et le nombre de points ” sur le contour du polygone par l'équation  Entrée Sortie  Application Des tentes sont disposées arbitrairement dans un camping, chacune est occupée par quelqu’un qui possède un poste de radio. On souhaite imposer un seuil de volume commun à tous les campeurs à ne pas dépasser pour que personne ne soit gêné par la musique d’un de ses voisins.  Définition Étant donné n points p1,...,Pn, ON souhaite déterminer une paire de points p;, p; qui minimise la distance euclidienne entre p; et p;.  Algorithme randomisé en temps linéaire Plusieurs algorithmes en O(n log n) ont été proposés pour ce problème classique, utilisant les techniques de balayage ou de diviser pour régner. Nous présentons un algorithme randomisé en temps linéaire,  1. Un polygone est simple si ses segments ne se croisent pas. 2. L'orientation normale suit le sens inverse des aiguilles d'une montre. 
12.3. PAIRE DE POINTS LES PLUS PROCHES 165  c'est-à-dire dont le temps de calcul en espérance est linéaire. D’après nos expériences, il n’est que légèrement plus efficace en pratique que l’algorithme par balayage, mais bien plus facile à implémenter.  Fraure 12.3 - Chaque case de la grille contient au plus un point. Au moment de considérer 9 un nouveau point p, il suffit de mesurer sa distance avec des points contenues dans les cases voisines (blanches).  L'idée est qu’à tout moment, on a déjà découvert une paire de points à distance d et on se demande s’il existe une autre paire avec une distance moindre. Pour cela, on divise l'espace en une grille de pas d/2 dans les deux directions. Chaque point appartient ainsi à une case de la grille. Soit P l’ensemble des points pour lesquels on a déjà vérifié que les distances entre toute paire de points de P est au moins d. Alors chaque case de la grille contient au plus un point de P. La grille est représentée par un dictionnaire G qui associe à chaque case le point de P qu'elle contient éventuellement. Au moment d’ajouter un point p à P et à G, il suffit de tester sa distance avec les points q contenus dans les 5 x 5 cases autour de la case de p, voir figure 12.3. Si une paire de points à distance d' < d a été découverte, on recommence la procédure du début avec une nouvelle grille de pas d’/2.  Complexité On suppose que les accès à G se font en temps constant et que le calcul de la case contenant un point donné est constant également. L'argument clé est que si les points donnés en entrée sont traités dans un ordre choisi uniformément au hasard, alors au moment du traitement du i-ème point (3 < / < n), on améliore la distance d avec probabilité 1/(i — 1). Donc la complexité en espérance est de l’ordre de D4 i/(i - 1), donc linéaire en n.  Détails d’implémentation Pour calculer la case associée à un point (x, y) dans une grille d'un pas donné, il suffit de diviser chaque coordonnée par pas et d’arrondir par défaut. Une attention particulière est à porter aux coordonnées négatives, car en Python et dans les autres langages par exemple |-1/2] donnera 0 au lieu du -1 voulu. Enfin, choisir un pas de grille de d/2 au lieu de d garantit la présence d’un seul élément par case, ce qui facilite le traitement. 
166 CHAPITRE 12. POINTS ET POLYGONES  from math import hypot # hypot(dx, dy) = sqrt(dx “* dx + dy * dy) from random import shuffle  def dist(p, q): | return hypot(p[6] - a[6], p[1] - q[11) |  def floor(x, pas): return int(x / pas) - int(x < 0)  def cell(point, pas): | x, y = point return (floor(x, pas), floor(y, pas))  def ameliore(S, d): G= {} # grille for p in S: | (a, b) = cell(p, d / 2.) for al in range(a - 2, a + 3): for bi in range(b - 2, b + 3): if (a1, b1) in G: q = G[ai, b1] pa = dist(p, a) if pq < d: return (pq, P, q) G[a, b] = p return None  def closest_points(S):  shuffle(S) assert len(S) >= 2 p = S[6] q = S[1] d = dist(p, a) while d > Q: r = ameliore(S, d) if r: (d, p, @) =r else: break  return (p, a) | 
12.4. POLYGONE RECTILINÉAIRE SIMPLE 167  12.44 Polygone rectilinéaire simple  Entrée Sortie pas simple simple  Définition Un polygone est rectilinéaire si ses arêtes alternent entre une orientation verticale et horizontale. Il est simple si ses côtés ne se croisent pas. Le but est de tester si un polygone rectilinéaire donné est simple.  Observation Si un polygone est rectilinéaire, alors chacun de ses points a un voisin vertical et un autre horizontal parmi celui qui le précède et celui qui le suit. Ainsi, les points du polygone peuvent être de type gauche ou droit et aussi de type haut ou bas, un simple test de voisinage suffit à les étiqueter, voir figure 12.4.  Algorithme en O(nlogn) par balayage.  gauche-haut [ ... FIGURE ’12..4 — Les types de points d’un polygone rectilinéaire. gauche-bas  L'algorithme va balayer les points du polygone dans l’ordre lexicographique des coordonnées, et maintenir dans une structure S l’ensemble des ordonnées des points gauches dont on n’a pas encore visité le voisin droit. Initialement, S est vide et pour chaque point, on effectue les traitements suivants. — Si (x,y) est identique au dernier point traité, il y a une intersection sur des points du polygone. — Si (x, y) est gauche, on vérifie que y n’est pas déjà dans S : cela signifierait qu’on a déjà visité un point gauche de même ordonnée qui attend son voisin droit, donc que deux segments horizontaux se superposent. — Si (x, y) est droit, nécessairement y se trouve dans S, puisque son voisin gauche a déjà été visité. On retire alors y de S. — Si (x, y) est bas, on ne fait rien. — Si (x, y) est haut, alors soit (x, y") son voisin bas. Si ce n’est pas le point qui a été traité juste avant, cela signifie que le segment (x, y") — (x, y) et un autre seg- ment vertical se superposent. Sinon, on inspecte S à la recherche d’éventuelles valeurs y” telles que y’ < y” < y : cela signifierait qu’un segment horizontal à l’ordonnée y” croise le segment vertical courant (x, y”) — (x, y), donc que le polygone n’est encore une fois pas simple. 
168 CHAPITRE 12. POINTS ET POLYGONES  Complexité Afin d’obtenir une complexité raisonnable, il faut que les opérations sur S soient effectuées efficacement, à savoir : — ajouter et retirer des éléments à S ; — savoir si S contient un élément d'un intervalle [a, b] donné. Si l’on code S comme un tableau t tel que t[y] = —1 si y € S et t[y] = 0 sinon, alors déterminer s’il existe un élément de S dans un intervalle [a, b] revient à chercher un —1 dans la plage entre f[a] et t[b]. Nous allons donc représenter t par un arbre de segments, voir section 4.5 page 61, pour que les requêtes de minimum d’une plage ainsi que de mise à jour de t s’effectuent en temps logarithmique, ce qui garantit une complexité de O(n log n) pour cet algorithme.  Détails d’implémentation Plutôt que de travailler sur les ordonnées des points, qui ne sont pas bornées, on s’intéresse à leur rang. Soit yo < ..., < Y la liste de toutes les ordonnées distinctes des points avec k < n/2. Ainsi y; € S si et seulement si t[i] = —1. Pour déterminer si S contient un élément y; dans l’intervalle [y;, yz|, il suffit de déterminer si le minimum de t entre f[i + 1] et t[k — 1] est -1.  def is_simple(polygon): n = len(polygon) order = list(range(n)) order.sort(key=lambda i: polygon[i]) # ordre lexicographique rank_to_y = list(set(p[1] for p in polygon)) rank_to_y.sort () y_to_rank = {rank_to_y[i]: i for i in range(len(rank_to_y))} S = RangeMinQuery([0] * len(rank_to_y)) # structure balayage for i in order: x, y = polygon[i] rank = y_to_rank[y]  # -- type de point right_x = max(polygon[i - 1][0], polygon{(i + 1) % n][0]) left = x < right_x  below_y = min(polygon[i - 1][1], polygon[Ci + 1) % n][1]) | high = y > below_y  ! if left: # il ne faut pas encore y dans S Ï if S[rank]: return False # inters. entre deux segm. horiz. S[rank] = -1 # ajouter y à S else: S[rank] = 0 # enlever y de S if high: lo = y_to_rank[below_y] # vérifier S entre lo + 1 et rank - 1 if (below_y != last_y or last_y == y or rank - lo >= 2 and S.range_min(lo + 1, rank)): return False # inters. entre segm. horiz. et vertic. last_y = y # mémoriser pour prochaine itération  return True 
13. Rectangles  Beaucoup de problèmes qui font intervenir des figures géométriques concernent les rectangles, par exemple quand il s'agit de plans de maisons ou d’affichages sur ordinateur. Parfois ils sont rectilinéaires, c’est-à-dire que leurs côtés sont parallèles aux axes, ce qui facilite leur traitement. Une technique algorithmique importante en géométrie est le balayage, qui est décrit en section 13.5 page 173.  13.1 Former des rectangles  Ficure 13.1 — Une signature commune entre les couples de coins opposés, les diagonales se coupent en leur milieu et sont de même longueur.  Définition Étant donné un ensemble S de n points du plan, on souhaite déterminer tous les rectangles qui ont leurs 4 coins dans S. Ces rectangles ne sont pas forcément rectilinéaires.  Algorithme en temps O(n*+m) où m est la taille de la solution. L‘observation clé est que les deux couples de coins diagonalement opposés d’un rectangle ont une signature en commun. Celle-ci est simplement composée du centre et de la distance entre les points. Il suffit alors de stocker dans un dictionnaire tous les couples de points p, q avec comme clé (c, d) où c = (p + q)/2 est le point médian entre p et q et d = |q — p| est la distance. Les couples ayant la même signature sont ceux qui forment les rectangles de S, voir figure 13.1.  Détails d'implémentation Afin d’être plus performant et de ne manipuler que des entiers dans les calculs, au moment du calcul de c, la division par 2 est ignorée, et pour le calcul de d, la racine est omise. 
170 CHAPITRE 13. RECTANGLES  idef rectangles_from_points(S): answ = 0 E pairs = {} |  for j in range(len(S)): for i in range(j): |  px, py = S[il ax, qy = S[j] center = (px + qx, py + qy)  dist = Cpx - qx) * (px - qx) + (py - qy) * (py - qy) sign = (center, dist) if sign in pairs: answ += len(pairs[sign]) pairs[sign].append(Ci, j)) else: pairs[sign] = [Ci, j)] return answ  13.2 Plus grand carré dans une grille  Définition Étant donné une image en noir et blanc sous la forme d’un tableau de n x m pixels, on souhaite déterminer le plus grand carré entièrement noir, voir figure 13.2.  v m 00 0  FrGure 13.2 — Un carré noir de taille maximale k ayant pour coin inférieur droit (i, ÿ) contient 3 carrés noirs de taille k—1 finissant en les coins (i, j—1), (8—1, j-1) et(à—1,7).  Algorithme en temps linéaire Ce problème se résout simplement par programma- tion dynamique. Supposons que les lignes soient numérotées de haut en bas et les colonnes de la gauche vers la droite. On dit qu’un carré finit en (i, j) si son extrémité en bas à droite est (i, /). Si ce carré est de côté k, il est composé des cellules (ÿ”, j’) pour i-k<i<ietj-k<j'<j. 
13.3. PLUS GRAND RECTANGLE DANS UN HISTOGRAMME 171  Pour chaque cellule (i, j) de la grille, on cherche le plus grand entier k tel que le carré de côté k finissant en (i, j) est tout noir. Notons A[i, j] cette valeur. Si la cellule (i, j) est blanche, alors A[i, j] = O, qui correspond à l’absence de carré. Tout carré noir de côté k contient 4 carrés noirs de côté k — 1. Donc A[i, j] = k pour k > 1 si et seulement si chacune des trois valeurs A[i — 1, j], A[i — 1,j — 1], A[i, j — 1] est au moins k — 1. Ces observations mènent vers la formule de récurrence  Afi, l = 0 si la cellule (;, j) est blanche, 254 1+mintA[i-1,j} Ai-1,j-1], A[L j-1]} sinon.  13.3 Plus grand rectangle dans un histogramme  Définition On nous donne un histogramme sous la forme d’un tableau d’entiers positifs ou nuls xo, … , *n_1- Le but est de placer un rectangle sous cet histogramme qui maximise sa surface. C’est-à-dire qu'il faut trouver un intervalle [€, r) qui maximise la surface (r — €) x h où la hauteur est h = minç<ier Xi-  Application Au fond de l’Océan Atlantique se trouve un câble de télécommu- nication reliant l’'Europe à l'Amérique. Les caractéristiques techniques de ce câble changent au cours du temps, en fonction des mouvements de la mer, des fluctuations de température, etc. Il existe donc à tout moment un débit maximal possible pour la transmission qui est fonction du temps. Changer de débit de transmission dans une connexion est possible mais nécessite un échange entre les terminaux qui empêche toute communication pendant un instant. Imaginons que vous connaissiez à l’avance les débits maximaux possibles pour chacune des 60 x 24 minutes d’une journée. Vous souhaitez trouver un intervalle de temps et un débit qui vous permettent de trans- mettre une quantité maximale d'information sans déconnexion. Ce problème revient à trouver un rectangle de surface maximale sous un histogramme.  10  12  15  16  Ls  Fioure 13.3 — Tant que l'histogramme croît, les coins sont empilés. Lorsque l'histogramme décroît, il faut dépiler les coins trop hauts, tester les candidats rectangles correspondants puis rempiler le dernier coin dépilé à plus basse hauteur. 
172 CHAPITRE 13. RECTANGLES  Algorithme en temps linéaire Il s’agit d'une méthode par balayage. Pour chaque préfixe du tableau xo,., X;-1, On maintient une collection de rectangles dont on n’a pas encore déterminé le côté droit. Ils sont définis par un couple d’entiers (£, h), où € est le côté gauche et h la hauteur. Il suffit alors de considérer des rectangles maximaux pour l’inclusion, donc h est le minimum sur xe, Xen1,, X— et € = O ou xe-1 < h. Ainsi le rectangle ne peut pas être agrandi à gauche ou vers le haut sans dépasser l’histogramme. On stocke ces couples d’entiers sur une pile ordonnée par h. Observation intéressante : les couples sont aussi ordonnés par €. Maintenant pour chaque valeur x;, il est possible qu’on ait trouvé le côté droit de certains rectangles. C’est notamment le cas de tous les rectangles codés (€, h) sur la pile avec h > x;. La largeur d’un tel rectangle est ; — . Mais la valeur x; va aussi créer un nouveau couple ({”, x;). Le côté gauche ?" est soit la valeur € du dernier rectangle qui a été dépilé, soit {’ = : s’il n’y a pas eu de dépilement, voir figure 13.3.  \ def rectangles_from_histogram (H) : Ï best = (float(’-inf'), 6, 6, 0) s=w | Ï H2 = H + [float(’-inf’)] # élément supplémentaire pour vider la pile for right in range(len(H2)): | x = H2[right] ! left = right | while len(S) > 0 and S[-1][1] >= x: left, height = S.pop() # (surface, gauche, hauteur, droite) | rect = (height * (right - left), left, height, right) if rect > best: best = rect S.append((left, x)) return best  13.4 Plus grand rectangle dans une grille  Application Étant donné un terrain à construire, parsemé d’arbres, on souhaite trouver un emplacement rectangulaire de surface maximale pour y placer une maison sans avoir à couper d’arbre.  Définition Étant donné une image noir et blanc sous forme d’un tableau de n x m pixels, on souhaite déterminer le plus grand rectangle entièrement noir. Un rectangle ici est l’intersection d’un intervalle de ligne et d’un intervalle de colonnes, voir figure 13.4.  Algorithme en temps linéaire La solution repose sur une réduction vers la re- cherche du plus grand rectangle sous un histogramme. Pour chaque ligne i, on cherche le plus grand rectangle noir qui a sa base sur la ligne /. Pour cela, on main- tient un tableau t qui pour chaque colonne j donne le nombre maximal k tel qu’entre (i, j) et (i, j — k + 1) tous les pixels soient noirs. Ainsi t définit un histogramme sous lequel on cherche un plus grand rectangle. Le tableau t se met à jour facilement d’une ligne à la ligne suivante, en fonction des couleurs des pixels de chaque colonne. 
13.5. UNION DE RECTANGLES 173  Ficure 13.4 — Un rectangle noir de surface maximale dans une grille.  def rectangles_from_grid(P, noir=1): rows = len(P) cols = len(P[6]) t = [0] #* cols best = None for i in range(rows): for j in range(cols): if P[il[j] == noir: t[j] = 1 else: | t[j] = 0 (area, left, height, right) = rectangles_from_histogram(t) alt = (area, left, i, right, i-height) if best is None or alt > best: best = alt return best  e E E S rr e é e e _ PR  13.5 Union de rectangles  Définition Étant donné n rectangles rectilinéaires, on souhaite calculer la surface de leur union. Avec les mêmes techniques, on peut également calculer le contour et le nombre de régions connexes.  Algorithme en O(n*) Une observation clé pour tous les algorithmes est que les extrémités des rectangles forment sur chaque axe au plus 2n points. Ces points forment une grille, composée de O(n°) cellules, qui sont soit entièrement couverts par un rectangle, soit disjoints de tout rectangle, voir figure 13.5. Une première solution simple consiste alors à déterminer un tableau de booléens qui indique pour chaque cellule si elle fait partie de l’union des rectangles. La surface de l’union peut alors être calculée par une simple somme des surfaces des cellules. Le travail effectué pour chaque rectangle est O(1*), ce qui mène à la complexité annoncée de O(n*). 
174 CHAPITRE 13. RECTANGLES  Ficure 13.5— Grille avec O(n) lignes et O(n) colonnes. Chaque cellule est soit incluse dans l’union des rec- tangles soit disjointe de celle-ci.  Algorithme en O(n*) par balayage. On balaye les rectangles avec une ligne horizon- tale de haut en bas. Les côtés gauche et droit des rectangles divisent l’axe horizontal en O(n) intervalles. À tout moment, on maintient un tableau de booléens qui indique pour chacun des intervalles combien de rectangles le recouvrent. En sommant les longueurs des intervalles couverts par au moins un rectangle, on peut déterminer la longueur de l’intersection de la ligne de balayage avec l’union des rectangles. Lors- qu'’on déplace le balayage de À unités vers le bas, il suffit de multiplier cette longueur avec À et la cumuler dans une variable qui compte la surface totale de l’union. La ligne de balayage avance d’événement en événement. Un événement repré- sente un côté haut ou bas d’un rectangle. Le traitement d’un côté haut va augmenter les compteurs des intervalles couverts par le rectangle, tandis que le traitement du côté bas correspondant va au contraire les diminuer. Cette partie coûte alors O(n), ce qui donne à l’algorithme complet la complexité O(n°).  Algorithme en O(nlogn) Une amélioration peut être obtenue par l’utilisation d'une structure de données similaire à celle utilisée pour les requêtes de minimum dans une plage d’indices : un arbre de segments, voir section 4.5 page 61. Cette structure est initialisée avec deux tableaux L et t de taille 2n — 1. L'idée est que les abscisses des coordonnées des rectangles divisent l’axe des abscisses en plusieurs segments. Le i-ème segment a une longueur L[i]. Lors d’un balayage de gauche à droite, on souhaite maintenir pour chaque segment le nombre de rectangles qui le contiennent. Cette information est stockée dans le tableau t. Les opérations suivantes sont permises sur la structure de données. — change(i,k,d) ajoute la valeur d aux entrées t[j] pour i < j < k. — cover() renvoie la somme Ÿ L[j] prise sur les indices / tels que t[j] # 0. L'opération change est appelée lorsque le balayage rencontre le côté haut d’un rec- tangle (d = 1) ou son côté bas (d = —1). La méthode cover sert à déterminer la taille de l’intersection du balayage avec les rectangles, information qui permet de déterminer la surface de l’union des rectangles, quand elle est multipliée avec le déplacement vertical à chaque fois que le balayage descend. La structure de données se compose d’un arbre binaire, dont chaque nœud p est responsable d’une plage d’indices I dans t (et donc aussi dans L). Il est muni de trois attributs : — f‘[p] est la somme de L[j} sur j € I. — c[p] est un nombre ajouté à tout t[j] pour j € I — s[p] est la somme de L[j] sur tout j € I tel que t[j] # 0. 
13.5. UNION DE RECTANGLES ; 175  La racine de l’arbre est le nœud 1 et le résultat de cover() est stocké dans s[1]. Le tableau t est stocké implicitement dans les attributs c. La valeur t[j] est la somme de c[p] sur tous les nœuds p le long du chemin de la racine à la feuille correspondante à j. Comme pour la structure de minimum sur une plage (minimum range query), la mise à jour se fait en temps logarithmique.  | class Cover_query: def __init__(self, _len):  ! assert _len != [] # on suppose _len ordonné self.N = 1 while self.N < len(_len): self.N #= 2 self.c = [0] * (2 * self.N) # --- couvert self.s = [0] * (2 * self.N) # --- score self.w = [0] * (2 * self.N) # --- longueur  for i in range(len(_len)): self.w[self.N + i] = _len[i] for p in range(self.N - 1, 06, -1): self.w[p] = self.w[2 * p] + self.w[2 * p + 1]  | def cover(self): return self.s[1]  def change(self, i, k, delta): self._change(1, O, self.N, i, k, delta)  def _change(self, p, start, span, i, k, delta):  | if start + span <= i or k <= start: # --- disjoint return if i <= start and start + span <= k: # --- inclus self.c[p] += delta else: | self._change(2*p, start, span // 2, i, k, delta)  self._change(2*p + 1, start + span // 2, span // 2, i, k, delta) if self.c[p] == 0:  if p >= self.N: # --- feuille self.s[p] = 6 else: self.s[p] = self.s[2 * p] + self.s[2 * p + 1]  else: self.s[p] = self.w[p]  La complexité de O(n log n) est justifiée par le tri des coordonnées horizontales et verticales. 
176 CHAPITRE 13. RECTANGLES  0/13/8 0/6/5 0/7/3 e/l/s 1/4/4 0/2/1 0/3/0 0/5/3 =|18 2 e 0/2/0 | 1/2/2 =|= 0/0/2 g 1/3/3 = 9000 00  e eN —— — de balayage  FiGURE 13.6 — Illustration du balayage et de la structure de données.  %def union_rectangles (R): | 1f-R-==—[]: | return 0 [] | C | j in range(len(R)): (x1, y1l, x2, y2) = R[j] assert x1 <= x2 and y1 <= y2 X.append(x1) X.append (x2) Y.append((y1, +1, j)) # générer événements Y.append((y2, -1, j)) | X.sort() | Y.sort() | X2i = {X[i]: i for i in range(len(X))} Ï _len = [X[i + 1] - X[i] for i in range(len(X) - 1)] | C = Cover_query(_len) area = 0 | last = 0 | for (y, delta, j) in Y: area += (y - last) # C.cover() last = y (x1, yl, x2, y2) = R[j] i X2i[x1] k = X2i[x27 C.change(i, k, delta) return ar:a  H < ° 4 ull 
13.6. UNION DE RECTANGLES DISJOINTS 177  13.6 Union de rectangles disjoints  Définition Étant donné n rectangles rectilinéaires disjoints, on souhaite déterminer tous les couples de rectangles adjacents.  Application Ceci peut servir pour calculer le contour de l’union par exemple, en retranchant du total des périmètres des rectangles la longueur du contact entre tous les rectangles adjacents. Cette dernière valeur doit être prise avec un facteur 2, car une unité de contact enlève une unité de périmètre de chaque rectangle. Une autre application est de déterminer les composantes connexes formées par les rectangles adjacents, voir figure 13.7.  2 3 Frcure 13.7 — Détecter l'adjacence entre les rectangles @ 4), (2,5), (3,5) et (4,5).  Algorithme en O(nlogn) par balayage. Nous expliquons d’abord comment déter- miner les couples des rectangles adjacents avec leurs côtés gauche et droit. Le cas des côtés haut et bas est identique. Une liste d’événements est constituée par les 4 coins de chacun des n rectangles. Chaque événement est un tuplet (x, y, i, c), où (x, y) sont les coordonnées du coin c du i-ème rectangle, et c peut prendre les valeurs 0,1,2,3 pour bas-droite, haut-droite, bas-gauche et haut-gauche respectivement. Puis les évé- nements sont traités dans l’ordre lexicographique, ce qui revient à un balayage des coins de gauche à droite, et au sein d’une même colonne de bas en haut. En cas de coins identiques, on traite d’abord les coins du haut des rectangles. Puis il suffit de maintenir la liste des rectangles pour lesquels on a déjà vu le coin bas dans la colonne, mais pas encore le coin haut. Par la garantie que les rectangles sont disjoints, cette liste contient au plus deux rectangles, un rectangle à gauche de la colonne et un à droite. Un coin bas ajoute un rectangle dans cette liste tandis qu’un coin haut l’enlève. Finalement, deux rectangles sont adjacents si et seulement s’ils se trouvent au même moment dans cette liste.  Variante Si dans l'énoncé du problème, on considère que deux rectangles parta- geant seulement un coin et non un segment de côté sont adjacents, alors il suffit de changer les priorités et traiter pour deux points identiques d’abord les coins bas puis les coins haut. 
14. (Ëalculs  Beaucoup de problèmes reposent sur des calculs à effectuer rapidement, par exemple des coefficients binomiaux modulo un certain nombre premier. Ce court cha- pitre regroupe quelques implémentations efficaces des manipulations sur les entiers classiques : arithmétique, évaluation d’expression, résolution de systèmes linéaires.  141 PGCD  Définition Étant donné deux entiers a, b on cherche le plus grand entier p tel que a et b puissent être exprimés comme multiples entiers de p, c’est le plus grand commun diviseur (PGCD). Le calcul du PGCD peut s’implémenter rapidement de manière récursive. Un moyen mnémotechnique : à compter de la deuxième itération, on se débrouille pour que le deuxième argument soit toujours plus petit que le premier : à mod b < b.  \ def pgcd(a, b): return a if b == 0 else pgcd(b, a % b)  14.2 Coefficients de Bézout  Définition Pour deux entiers a et b on souhaite déterminer deux entiers u et v tels que au + bv = d où d est le PGCD de a et b. Ce calcul repose sur une observation similaire. Si a = qb+r, au+bv = d correspond à (qb + r)u + bv = d soit bu’ + rv’ = d pour  w =qu+v u= y = , / v'=u v=u'—qu  Ce calcul termine en O(log a+log b) étapes. En effet, le premier paramètre diminue de moitié tous les deux appels.  Variante Certains problèmes s’intéressent à calculer des nombres très grands, et par conséquent demandent de renvoyer la réponse modulo un grand nombre premier p afin de tester si la solution est bonne. Comme p est premier, on peut facilement diviser par un entier à non multiple de p : à et p sont premiers entre eux, donc leurs coefficients de Bézout vérifient au + pv = 1, ainsi au vaut 1 modulo p et u est l’inverse de a. Donc pour diviser par a, on peut aussi multiplier par u. 
180 CHAPITRE 14. CALCULS  \ def bezout(a, b): | if b == 6: | return (1, 0) | else: Ï u, v = bezout(b, a % b) return (v, u - (a // b) * v)  \ def inv(a, p)d: | return bezout(a, p)[6] % p  14.3 Coefficients binomiaux  Lorsqu’on calcule (;), il est risqué de calculer n(n—1) - - - (n—k+1) et k! séparément, car un dépassement de capacité est possible. On peut plutôt s'appuyer sur le résultat qu‘un produit de ; entiers consécutifs contient forcément un terme divisible par i. Îdef binom(n, k)E | prod = l for i in range(k):  prod = (prod * (n - i)) // (i + 1) return prod  Dans la plupart des problèmes, le calcul des coefficients binomiaux sera à effec- tuer modulo un nombre premier p. Le code devient alors le suivant, de complexité O(k(log k + log p)), en s'appuyant sur le calcul des coefficients de Bézout.  \ def binom_modulo(n, k, p): prod = 1 for i in range(k): prod = (prod * (n - i) * inv(i + 1, p)d) % p return prod  Une alternative est de calculer le triangle de Pascal par programmation dyna- mique, qui peut être intéressant si (;) est à calculer pour de nombreuses paires n, k. Coefficient binomial par programmation dynamique  144 Exponentiation rapide  Définition Étant donné a, b on souhaite calculer a°. À nouveau, comme le résultat peut être grand, souvent il est demandé de faire ce calcul modulo un entier q donné, mais ceci ne change pas la nature du problème.  Algorithme enO(logb) L'approche naïve effectue b-1 multiplications de a. Mais on peut calculer rapidement des puissances de a de la forme a', a°,a*,a*, en utilisant la relation a” - a* = a””. Une astuce consiste donc à combiner ces valeurs selon la décomposition binaire de b. Exemple :  fl13 ps a8+4+1  =a-a-a. 
14.5. NOMBRES PREMIERS ' 181  Pour ce calcul, il suffit de produire O(log, b) puissances de a.  def fast_exponentiation(a, b, a): assert a >= 0 and b >= 0 and q >= 1 |  ap2 = (ap2 * ap2) % q return result  p=06 # ne sert qu’à la documentation | p2 =1 #2-p | ap2 = a % q # a % (2 4 p) | result = 1 while b > 0: | if p2 & b > 0: # décomposition bin. de b contient a*(24p) | b -= p2 result = (result * ap2) % à p+=1 | p2 *= 2 |  Variante Cette technique peut s’appliquer aussi à la multiplication de matrices. Soit A une matrice et b un entier positif. L’exponentiation rapide permet de calculer A? en faisant seulement O(log b) multiplications de matrices.  14.55 Nombres premiers  Étant donné n, on cherche tous les nombres premiers inférieurs à n. Le crible d’Ératosthène est une méthode très simple pour effectuer cette tâche. On part d’une liste de tous les entiers inférieurs à n dont on barre initialement O et 1. Puis pour chaque p = 2,3,4,...,n — 1, si p n’est pas barré alors il est premier, et dans ce cas on barre tous les multiples de p. La complexité de cette procédure est délicate à analyser, elle vaut O(nloglog n).  Détails d'implémentation L'implémentation proposée fait l’économie de cocher 0, 1 ainsi que les multiples de 2. Dans ce cas, il est important que l’itération sur p fasse des pas de 2, pour ne tester que les nombres impairs.  ‘ def eratosthene(n): P = [True] # n answ = [2] for i in range(3, n, 2): | if P[i]J: answ.append(i) for j in range(2 * i, n, i): P[j] = False return answ ‘  146 Évaluer une expression arithmétique  Définition Étant donné une expression suivant une certaine syntaxe fixée, on sou- haite construire l'arbre syntaxique ou alors évaluer la valeur de l’expression, voir figure 14.1. 
182 CHAPITRE 14. CALCULS  ... Ficure 14.1 — L'arbre associé à l’expression 2 + (3 x 4)/2.  Approches En général, ce problème est résolu en découpant la chaîne contenant l’expression en un flux d'unités lexicales, à l’aide d’un outil appelé scanneur ou lexeur. Puis en fonction des unités lexicales observées on construit l’arbre syntaxique en suivant une grammaire, à l’aide d’un outil appelé parseur. Mais si la syntaxe est similaire à celle des expressions arithmétiques, on peut utiliser une méthode d’évaluation utilisant deux piles, qui est plus facile à mettre en œuvre. Une pile contient des valeurs et l’autre des opérateurs. Chaque valeur rencontrée est mise telle quelle sur la pile des valeurs. Pour chaque opérateur p rencontré, avant de le déposer sur la pile des opérateurs, il faut effectuer le traitement suivant : tant que le sommet q de la pile des opérateurs a une priorité au moins aussi grande que p, on dépile q, ainsi que les deux dernières valeurs a, b, puis on empile sur la pile des valeurs l’expression a q b (le résultat de l'opération q aux opérandes a, b). De cette manière, l’évaluation des opérateurs est retardée jusqu'à ce que la règle de priorité des opérateurs force l’évaluation, voir figure 14.2.  lecture pile de valeurs pile d'opérateurs  2 2 0 + 2 + 3 2,3 - e 2,3 +} 4 2,34 +,# / 2,12 - 2,12 +/ 2 2,12,2 +,/ ; 2,6 + 8 0  FIGURE 14.2 — Exemple de traitement de l’expression 2 + 3 x 4/2 avec le marqueur « ; » de fin d’expression.  Exemple tableur Considérons un tableur où chaque cellule peut contenir soit une valeur, soit une expression arithmétique. Cette expression peut être composée de constantes numériques et d’identifiants de cellules, reliés par des opérateurs - + * /et des parenthèses. 
14.6. ÉVALUER UNE EXPRESSION ARITHMÉTIQUE 183  Nous représentons une expression soit par un entier, soit par une chaîne de carac- tères, soit par un triplet composé d’un opérateur et de deux opérandes. L'évaluation numérique d’une expression est réalisée par cette fonction récursive, où cell est un dictionnaire, associant aux noms de cellules leurs contenus.  def arithm_expr_eval(cell, expr): if isinstance(expr, tuple): (left, op, right) = expr 1 = arithm_expr_eval(cell, left) r = arithm_expr_eval(cell, right)  if op == ’+': return l +r if'op == =. | return 1l -r d£ op == "*"; return 1l # r 1f£ op ==->/"  return 1 //r elif isinstance(expr, int): return expr else: cell(expr] = arithm_expr_eval(cell, cell[expr]) return cell [expr]  L'arbre syntaxique est construit suivant la méthode décrite ci-haut. Notez le trai- tement particulier des parenthèses. Une parenthèse ouvrante est toujours empilée sur la pile des opérateurs sans autre traitement. Puis une parenthèse fermante provoque le dépilement avec construction des expressions jusqu’à ce que le sommet de la pile des opérants soit la parenthèse ouvrante correspondante, qui est alors dépilée. Pour vider complètement les piles en fin de traitement, nous ajoutons au flot un marqueur «; » de fin d’expression, et lui affectons la priorité minimale.  priority =T O CI 14704052 ATS SRS SO e e 4N -47  def arithm_expr_parse(line): vals = [1 ops = [] | for tok in line + [*’;*1: if tok in priority: # tok est un opérateur | while tok != '(’ and ops and priority[ops[-1]] >= priority[tok]: right = vals.pop() | left = vals.pop() vals.append((left, ops.pop(), right)) if tok == ’): ops.pop() # ceci est la ’(” correspondante else: | ops.append(tok) elif tok.isdigit(O: # tok est un entier Ï vals.append(int (tok)) else: # tok est un identifiant | vals.append(tok) return vals.pop() 
184 CHAPITRE 14. CALCULS  Piège Une erreur souvent commise lors de l‘implémentation de ce code est d'écrire  vals.append}{va&.s.pop(). 6pS-DDPO. vals.ñop()))  ce qui a l’effet indésirable d'inverser les valeurs gauche et droite de l'expression, à cause de l’ordre d’évaluation des arguments de la méthode append.  14.7 Systèmes d’équations linéaires  Définition Un système d’équations linéaires est composé de n variables et m équa- tions linéaires. Formellement, une matrice À de dimension n x m est donnée ainsi qu’un vecteur colonne b de taille m. Le but est de trouver un vecteur x tel que Ax = b.  Application : marche aléatoire Soit un graphe connexe avec des probabilités ins- crites sur chaque arc. La somme des poids des arcs sortants est donc égale à 1. Un tel graphe s’appelle une chaîne de Markov. Une marche aléatoire est une marche qui débute dans un sommet u, puis pour chaque sommet courant u traverse un arc (u, v) choisi avec la probabilité inscrite. On cherche à connaître pour chaque sommet v le temps espéré x, pour que la marche atteigne v. Alors par définition x = 0 et Xp = EulXu + 1)Puv, OÙ Puv eSt la probabilité inscrite sur l'arc (u, v) ou 0 s’il n’existe pas. Mais il existe une autre application concernant les marches aléatoires. Au bout de t étapes, il existe pour chaque sommet une certaine probabilité de présence du marcheur. Dans certains cas, cette marche tend vers une distribution stationnaire sur les sommets. Calculer cette distribution revient à résoudre un système d'équations linéaires, où À code essentiellement les probabilités des arcs, et la solution recherchée x est la distribution stationnaire.  Application : système de poids et de ressorts Imaginons un système composé de boules lourdes reliées par des ressorts, dont le poids est négligeable. Certains des ressorts sont accrochés au plafond, et les ressorts peuvent être compressés ou étirés, voir figure 14.3. Étant donné la position et le poids des boules, on souhaite savoir si le système est stable ou s’il va se mettre en mouvement. Le but est donc de trouver des valeurs pour chaque ressort, représentant la force exercée sur les extrémités, telles que les forces s’annulent en chaque boule, y compris la gravitation. C'est de nouveau un problème de résolution de système d’équations linéaires.  Application : intersection géométrique En géométrie, lignes et hyperplans sont définis par des équations linéaires. Déterminer leur intersection revient à résoudre un système d’équations linéaires.  Algorithme en O(n?m) Si A était la matrice d'identité, alors la solution du système serait b. On va diagonaliser A pour obtenir une forme la plus proche possible de cette situation idéale. Pour cela, on va appliquer des transformations qui préservent les solutions du système, comme changer l’ordre des variables, échanger deux équations, multiplier une équation avec une constante, et ajouter une équation à une autre. 
14.7. SYSTÈMES D'ÉQUATIONS LINÉAIRES 185  Ficure 14.3 — Un système de poids et ressorts.  Afin de faciliter ces manipulations et ne pas modifier les paramètres À et b, nous stockons le système d’équations dans une matrice S, qui est composée d’une copie de À à laquelle on ajoute une colonne contenant b et une ligne supplémentaire contenant les indices des variables. Ainsi, quand des colonnes sont échangées dans S, il est possible de retrouver à quelle variable chacune des colonnes correspond. L'invariant est le suivant : au bout de K itérations, les k premières colonnes de S sont toutes zéro, sauf sur la diagonale où il y a des 1, voir figure 14.4. Pour obtenir l'invariant, on divise la k-ième ligne de S — donc la k-ième équation — par S[k,k] s’il est non nul, ce qui amène un 1 à l’indice (k, k). Puis on soustrait la k-ième équation de toute équation / # k, mais multipliée avec le facteur S[i, k], ce qui amène un 0 en les indices (i, K) pour tout ; # k, comme requis par l’invariant. Que faire cependant si S[k, k] est nul? On commence avant ces opérations par chercher un élément S[i, j] de plus grande valeur absolue dans le rectangle entre S[Kk, k] et S[m — 1, n — 1]. Puis on échange les colonnes k et j ainsi que les lignes k et i. Ces opérations préservent les solutions du système. Mais que faire si ce rectangle ne contient que des zéros ? Dans ce cas, la diagona- lisation est terminée, et il faut extraire la solution de la manière suivante. Si la diagonalisation a terminé prématurément au bout de k itérations, et que les lignes de S de k à m — 1 sont toutes à zéro, alors il faut vérifier la dernière colonne de ces lignes. S’il existe une entrée avec une valeur v non nulle, alors elle représente la contradiction 0 = v et donc on peut annoncer que le système n’a pas de solution. Sinon, le système admet au moins une solution. Soit k le nombre d’itérations effectuées par la diagonalisation. Nous avons k < min{n, m}. Si k < n, alors le système à plusieurs solutions. Pour en choisir üne, nous décidons de poser à 0 les variables correspondantes aux colonnes k à n — 1 dans S. Puis les autres valeurs des autres variables sont données dans la dernière colonne, forcées par la diagonale de 1 dans S. Finalement si k = n, alors la solution est unique. 
186 CHAPITRE 14. CALCULS  296029298585— 20090948 oos.+-oc  Fraure 14.4 — Structure de l'invariant pour k = 3.  Détails d’implémentation Comme on travaille avec des nombres flottants, avec des erreurs de précision pendant les calculs, il faut ajouter de la tolérance lors des tests d’égalité à Zéro. Au moment de retrancher la k-ième ligne à la i-ème ligne, il faut affecter le coefficient S[i][k] à une variable fact, car l'opération va changer la valeur de S[i][k]. Pour calculer la solution exactement, on peut travailler avec des fractions au lieu de nombres flottants. Dans ce cas, il est important de réduire les fractions à chaque itération, sinon les numérateurs et dénominateurs de la solution peuvent contenir un nombre exponentiel de chiffres. Sans la normalisation, l’algorithme de Gauss-Jordan n’est pas polynomial, mais heureusement la classe Python Fraction réduit les fractions à chaque opération.  Variantes Si la matrice est creuse, c’est-à-dire qu'il y a un nombre faible d’entrées non nulles par ligne et par colonne, on peut réduire le temps de calcul de O(n*m) à O(n). 
14.7. SYSTÈMES D'ÉQUATIONS LINÉAIRES 187  \def is_zero(x): # tolérance return -le-6 < x and x < 1e-6 i # remplacer par x == 0 si on travaille avec Fractions | \ GJ_ZERO_SOLUTIONS = 06 # codes retour  | GIJLUNE_SOLUTION = 1 GJ_PLUSIEURS_SOLUTIONS  N v  def gauss_jordan(A, x, b):  n = len(x) m = len(b) assert len(A) == m and len(A[0]) == n S = [] # mettre système dans une unique matrice S  for i in range(m): S.append(A[i][:] + [b[i]])  S.append(list(range(n))) # indices dans x k = diagonalize(S, n, m) Ï if k <m: |  for i in range(k, m): if not is_zero(S[i][n]): return GJ_ZERO_SOLUTIONS for j in range(k): x[S[(m][j]] = SCIj][n] if k <n: for j in range(k, n): ! x[S[m][j]] = © return GJ_PLUSIEURS_SOLUTIONS return GJ_UNE_SOLUTION  \ def diagonalize(S, n, m): for k in range(min(n, m)): val, i, j = max(Cabs(S[i][j]), i, j) for i in range(k, m) for j in range(k, n)) if is_zero(val):  return k S[i], S[k] = S[k], S[i] # échanger lignes k, i | for r in range(m + 1): # échanger colonnes k, j SIr][j], SIr][K] = SCIr][Kk], SIr1[j] pivot = float(S[k][k]) # sans float si on trav. avec Fractions for j in range(k, n + 1): S[IK][j] /= pivot # diviser ligne k par pivot for i in range(m): # enlever ligne k pondérée de la ligne i if i != k:  fact = S[(i][k] for j in range(k, n + 1): S[iJ[j] -= fact * SIKJ[j] | return min(n, m) | 
188 CHAPITRE 14. CALCULS  Frcure 14.5 — Avec quelles parenthèses faut-il multiplier une séquence de ma- trices afin de minimiser le nombre total d’opérations ?  148 Multiplication d’une séquence de matrices  Définition Soit n matrices M1,. , M,, où la i-ème matrice a r; lignes et c; colonnes, avec c; = r pour tout 1 < / < n. On souhaite calculer le produit M,Mz - - - Mn avec le moins d’opérations possibles. Par associativité, il est possible de placer les parenthèses de différentes manières. Nous avons l’égalité suivante par associativité du produit matriciel, mais les complexités de ces calculs peuvent différer.  (((M,M2)M3)M4 = M,(Mz2(Mz(M4))) = (M1M2)(M3M4),  Pour multiplier deux matrices M; et M;,1, on utilise l’algorithme standard qui effectue r;c;c;,, multiplications numériques. Le but est de trouver comment placer les parenthèses afin de multiplier au moindre coût, voir figure 14.5.  Algorithme en O(n*) La formule de récurrence ! est simple. La dernière multiplica- tion multiplie le résultat de M; - - - My avec le résultat de M;+1 - - - M, pour un certain 1 < k< n. Soit opt(i, ÿ) le coût minimal pour calculer M; - - - M;. On a alors s(i, /) = 0 et pouri<j opt(i, j) = g}(îä}(opt(i, k) + opt(k + 1, ÿ) + ric,C;. (14.1)  Si on souhaite calculer le coût de l’ordre optimal mais aussi l’ordre lui-même, alors on doit enrichir la matrice opt de l’indice k qui a réalisé la minimisation (14.1). C'est ce qui est fait dans l’implémentation suivante. La fonction opt_mul t(M,opti,j) multiplie ensuite de manière optimale M;-- - M; suivant les informations contenues dans opt. Notez l’ordre dans lequel les indices i, j sont traités. En considérant l’ordre crois- sant en / — i, il est assuré que les valeurs pour les couples i, k et k + 1, j nécessaires pour déterminer le minimum dans (14.1) auront déjà été calculées.  1. La complexité ne concerne que le calcul du placement optimal de parenthèses, pas la multiplication de matrice elle-même. ; 
14.8. MULTIPLICATION D'UNE SÉQUENCE DE MATRICES 189  !  \ def matrix_mult_opt_order (M) : | n = len(M) r = [len(Mi) for Mi in M] c = [len(Mi[0]) for Mi in M]  : def  \ def  opt = [[6 for j in range(n)] for i in range(n)] arg = [[None for j in range(n)] for i in range(n)] for j_i in range(l, n): # boucler sur i, j par j - i croissant for i in range(n - j_i): j=i+iji opt[i]l[j] = floatC'inf’) for k in range(i, j): alt = opt[i][k] + opt[k + 1][j] + r[i] * c[k] * <[j] if opt[il[j] > alt: opt[ill[j] alt arg[ill[i] k return opt, arg  matrix_chain_mult (M) : opt, arg = matrix_mult_opt_order (M) return _apply_order(M, arg, 60, len(M)-1)  -apply_order(M, arg, i, j):  # --- multiplication de matrices de M[i] à M[j] inclu if i == j: return M[i] else: k = arg[il][j] # --- suivre le placement de parenthèses A = _apply_order(M, arg, i, k) Ï B = _apply_order(M, arg, k + 1, j) | row_A = range(len(A)) | row_B = range(len(B)) col_B = range(len(B[0]))  return [[sum(A[a][b] * B[b][c] for b in row_B) for c in col_B] for a in row_A]  Il existe un meilleur algorithme en O(n log n) (voir [15]) qui n’est pas présenté ici. 
15. Exploration exhaustive  Certains problèmes combinatoires ne présentent pas de structure connue qui per- mettrait de les résoudre avec un algorithme garanti en temps polynomial. Une mé- thode est alors d’explorer exhaustivement l’espace des solutions potentielles. Le mot combinatoire se réfère ici à des structures qui se combinent à partir de structures plus simples. Par exemple, la construction d’arbres par combinaison de sous-arbres, ou la formation de pavages par assemblage de tuiles. L'exploration exhaustive consiste alors à parcourir l’arbre implicite des constructions possibles pour détecter une so- lution. Les nœuds de l’arbre représentent des constructions partielles, et lorsqu’elles ne peuvent plus être complétées en solutions, par exemple par la violation d’une contrainte, l'exploration rebrousse chemin pour explorer d’autres branches. C’est pourquoi en anglais cette technique est appelée backtracking (retour sur trace en fran- çais). Nous allons exposer ces principes via un exemple simple.  15.1 Tous les chemins pour un laser  Définition Soit une grille rectangulaire, entourée d’un bord comportant deux ou- vertures dans la première ligne, à gauche et à droite. Certaines des cellules de la grille comportent des miroirs double-face, qui peuvent être placés selon deux orientations possibles, diagonale ou antidiagonale, voir figure 15.1. Le but est d’orienter les mi- roirs de sorte qu‘un rayon laser qui entrerait par l’ouverture gauche ressorte par l’ouverture droite. Le rayon laser traverse les cellules vides de manière horizontale ou verticale et lorsqu'il rencontre un miroir, il est dévié de 90 degrés vers la droite ou vers la gauche, succant l’orientation du miroir. Si le rayon rencontre la bordure de la grille, il est absorbé.  Algorithme Aucune solution en temps polynomial n’est connue pour ce problème. Nous proposons une résolution par exploration exhaustive. Pour chaque miroir, nous stockons un état qui peut être de trois types : les deux orientations, et un type « sans orientation ». Initialement, les miroirs sont tous sans orientation. Puis le parcours du rayon laser est simulé, en entrant par l’ouverture gauche. — Lorsqu‘un miroir orienté est rencontré, le rayon est réfléchi en fonction de l’orientation. — Lorsque le rayon est sur un miroir sans orientation, deux appels récursifs sont effectués, un pour chaque orientation du miroir. Si l’un de ces appels trouve une solution, elle est renvoyée. Si aucun des appels ne trouve une solution, le miroir est remis dans la position sans orientation et un code d’échec est renvoyé. 
192 CHAPITRE 15. EXPLORATION EXHAUSTIVE  Ficure 15.1 — Une orientation des mi- roirs qui permet au laser de traverser  la grille.  — Lorsque le rayon touche le bord, la procédure récursive termine avec un code d’échec. L'exploration rebrousse alors chemin, c’est le backtracking. — Finalement lorsque l’ouverture droite est atteinte, la solution est renvoyée.  Détails d’implémentation Les n miroirs dont les positions sont données en entrée sont numérotés de 0 à n — 1. Deux miroirs fictifs sont ajoutés de numéros n etn +1 et placés sur les deux ouvertures. Le programme produit un tableau L avec les coordonnées et les indices des miroirs. Les 4 directions possibles du rayon sont représentées par des entiers de 0 à 3, et les deux orientations par 0,1. Un tableau reflex de dimension 4 x 2 indique le changement de direction fait par un rayon qui arriverait d’une direction donnée sur un miroir d’une orientation donnée. Dans un précalcul, les miroirs successifs d’une même ligne et même colonne sont reliés entre eux, à l’aide d’un tableau succ. Pour un miroir ; et une direction d, l’entrée succ[i][d] indique l'indice du miroir rencontré lorsqu’un rayon quitte le miroir / en direction d. Cette entrée est None si le laser est renvoyé sur le bord. Pour renseigner le tableau succ, la liste L est parcourue d’abord ligne par ligne, puis colonne par colonne. Notez la fonction de tri qui inverse indice ligne et colonne pour le tri lexicographique. Les variables last_i, last_r, last_c contiennent les informations du dernier miroir vulors du parcours. Si le dernier miroir est dans la même ligne que le miroir courant (pour le parcours ligne par ligne) alors les deux doivent être reliés en indiquant leurs références respectives dans succ.  # directions UP = 0 LEFT 1 DOWN 2 RIGHT = 3 # orientations None:? 8:/ 1:\  # arrivée UP LEFT DOWN RIGHT reflex = [[RIGHT, LEFT], [DOWN, UP], [LEFT, RIGHT], [UP, DOWN]] 
15.1. TOUS LES CHEMINS POUR UN LASER 193  def laser_mirrors(rows, cols, mir): # construire les structures n = len(mir) | orien = [None] * (n + 2) orien[n] = 0 # orientations arbitraires pour les ouvertures Ë orien[n + 1] = 6 succ = [[None for direc in range(4)] for i in range(n + 2)] L = [(mir[i][0], mir[i][1], i) for i in range(n)]  L.append((60, -1, n)) # entrée L.append((0, cols, n + 1)) # sortie last_r = None | for (r, c, i) in sorted(L): # balayage par ligne if last_r == r: |  succ[i][LEFT] = last_i succ[last_i][RIGHT] = i last_r, last_i = r, i | last_c = None for (r, c, i) in sorted(L, key=lambda tup_rci: (tup_rci[1], tup_rci [0])) ; 1f‘last/c == c: # balayage par colonne succ[i][UP] = last_i succ[last_i][DOWN] = i last_c, last_i = c, i if solve(succ, orien, n, RIGHT): # exploration | return orien[:n] else: return None  à  L'exploration proprement dite est implémentée par des appels récursifs. Pour ce genre de problèmes difficiles, les instances sont souvent petites, et il n’y a pas de risque de dépassement de la pile lors des appels récursifs. Notez qu’après avoir exploré de manière infructueuse deux sous-arbres correspondant aux deux orien- tations possibles du miroir j, le programme remet le contenu des variables en état, c’est-à-dire dans la position sans orientation.  def solve(succ, orien, i, direc): assert orien[i] != None j = succ[i][direc] if j is None: # cas de base return False if j == len(orien) - 1: return True if orien[j] is None: # tester les 2 orientations for x in [60, 1]: orien[j] = x if solve(succ, orien, j, reflex[direc][x]): return True | orien[j] = None return False else: | return solve(succ, orien, j, reflex[direc][orien[j]]) 
194 CHAPITRE 15. EXPLORATION EXHAUSTIVE  15.2 Couverture exacte  La Rolls-Royce des algorithmes d’exploration exhaustive est l'algorithme des liens dansants [18], qui résout le problème général de couverture exacte. Beaucoup de problèmes se réduisent vers le problème de couverture exacte, donc maîtriser cet algorithme est un réel avantage en compétition.  Définition Le problème de couverture exacte se compose d'un ensemble de points U, appelé univers, et d’une collection de sous-ensembles de U, S c 2%, voir fi- gure 15.2. On dit qu’un ensemble À € U couvre x € U si x € À. Le but est de trouver une sélection des ensembles de S, c’est-à-dire une collection S* C S qui couvre chaque élément de l’univers exactement une fois. En entrée, on reçoit une matrice binaire M, dont les colonnes représentent les éléments de l’univers, et les lignes les ensembles de la collection S. L'élément {x, A) de la matrice vaut 1 ssi x € À. En sortie, il faut produire un ensemble de lignes S" tel que la matrice restreinte à S“ contienne exactement un 1 dans chaque colonne.  Applications Le jeu sudoku peut être vu comme un problème de couverture exacte, voir section 15.3 page 200. C’est aussi le cas du problème de pavage, où à partir d’un ensemble de tuiles, il faut recouvrir une grille de dimensions m x n sans chevau- chement. Chaque tuile doit être utilisée exactement une fois, et chaque cellule de la grille à paver doit être recouverte par exactement une tuile. Les cellules et les tuiles forment donc les éléments de l’univers, tandis que les placements de tuiles constituent les ensembles.  Algorithme desliens dansants L'algorithme est une exploration exhaustive comme décrit dans la section précédente. Sa particularité réside dans le choix des structures de données dans son implémentation. Tout d’abord, l’algorithme choisit un élément e, idéalement inclus dans un mi- nimum d’ensembles de S, donc le plus contraint. Ce choix a de bonnes chances de générer des petits arbres d’exploration. Comme la solution doit couvrir e, elle contient forcément un et un seul des ensembles A € S avec e € À. L'espace de recherche des solutions est alors divisé par le choix d’un tel sous-ensemble. Pour chaque ensemble A avec e € À, une solution S" est recherchée pour le sous-problème, et dans le cas positif, la solution S* U {A} est renvoyée comme solution au problème initial. Le sous-problème en question est construit à partir de (U, S) en retirant les élé- ments de À de U, car chaque élément f € À est déjà couvert par À, et il ne faut pas le couvrir une deuxième fois. De plus, tous les ensembles B intersectant A sont retirés de S, sinon des élements de ÀA seraient couverts plusieurs fois, voir figure 15.2. Reformulée pour le formalisme de la matrice d’inclusion M, la structure générale de l’algorithme est la suivante. Si la matrice M est vide, alors il faut renvoyer l’en- semble vide, qui est une solution. Sinon, chercher une colonne c de M contenant le moins de 1 possible. Boucler sur toutes les lignes r qui pourraient couvrir c, c’est- à-dire M, = 1. Pour chaque ligne r, effectuer les étapes suivantes : enlever de M la ligne r ainsi que toutes les colonnes c’ couvertes par r (M,« = 1). Si la matrice ainsi obtenue admet une solution S alors renvoyer S U {r}. Sinon restituer M. 
15.2. COUVERTURE EXACTE ' 195  élément 3  Frcure 15.2 — Trois sous-problèmes résultant du choix de l'élément e = 3. Notez que le 3° sous-problème est sans solution, car l’élément 0 ne peut pas être couvert.  Représentation compacte par chaînage Pour représenter de manière compacte la matrice creuse M, on ne stocke que les cellules M contenant un 1, et on les relie par double chaînage verticalement et horizontalement, voir figure 15.4. Ainsi on peut parcourir très facilement tous les r tels que M, = 1 en utilisant les liens verticaux et tous les c’ tels que M,- = 1 en utilisant les liens horizontaux. Chaque cellule dispose donc de 4 champs L, R, U, D pour coder ces doubles chaînages. Pour chaque colonne on dispose également d’une cellule d’en-tête, qui fait partie du chaînage vertical, pour avoir un accès à la colonne. Au moment de la création de la structure, les en-têtes sont stockées dans un tableau col indexé par les numéros de colonnes. Enfin, on dispose d’une cellule particulière h, qui fait partie du chaînage horizontal des en-têtes de colonnes pour pouvoir y accéder. Cette cellule n’utilise pas les champs U,D. 
196 CHAPITRE 15. EXPLORATION EXHAUSTIVE  ——" > — —55— —— C-‘ — — —— —— —— — C ——  Ficure 15.3 — L'opération hide retire un élément c d’une liste doublement chaînée. En n’effaçant pas les pointeurs de c, il est alors facile de le réinsérer dans sa position initiale.  Chaque cellule possède deux champs supplémentaires S, C, qui ont un rôle dé- pendant du type de cellule. Pour les cellules de matrices, S contient le numéro de la ligne et C l’en-tête de la colonne. Pour les cellules d’en-tête de colonne, S contient le nombre de 1 dans la colonne, et le champ C est ignoré. La cellule h ignore ces deux champs.  class Cell: def __init__(self, horiz, verti, S, C): self.s = S self.C = C | if horiz: self.L = horiz.L self.R = horiz self.L.R = self self.R.L = self else: Ï self.L = self | self.R = self Ï if verti: | self.U = verti.U self.D = verti self.U.D = self self.D.U = self else: self.U = self self.D = self  def hide_verti(self): self.U.D = self.D self.D.U = self.U  def unhide_verti (self): self.D.U self | self.U.D self Ï  def hide_horiz(self): self.L.R self.R self.R.L self.L 
15.2. COUVERTURE EXACTE ' 197  def unhide_horiz(self): self.R.L = self self.L.R = self  Les liens L'idée de l'algorithme des liens dansants vient d’une observation faite par Hitotsumatsu et Noshita en 1979 et décrite par Donald Knuth en 2000 [18]. Pour retirer un élément c d’une liste doublement chaînée, il suffit de changer la cible des pointeurs des voisins, voir figure 15.3. Pour le remettre dans la liste, il suffit d’effectuer les opérations inverses dans l’ordre inverse. On utilise cette observation pour facilement enlever et remettre une colonne dans la matrice. L'opération de couvrir une colonne c consiste à la retirer de la liste horizontale des en-têtes de colonnes et pour chaque ligne r avec M,. = 1, d’enlever du chaînage vertical toutes les cellules correspondant aux positions (r,c’) dans M avec M,- = 1. Il faut veiller à maintenir à jour le compteur S de l’en-tête de cellule c’, donc le décrémenter.  c = l'en-tête de la colonne à cacher doit être une cellule d’en-tête  def cover(c): assert c.C is None c.hide_horiz()  # #  i = c.D while i != c: J = 1R  wvhile j != i: j.hide_verti () j.C.5S--= 1 # une entrée de moins dans cette colonne j = jR i  i=i.D  def uncover(c): assert c.C is None  i=c.U while i != c: j=i.L while j != i:  j.C.$S + 1 # une entrée de plus dans cette colonne j.unhide_verti () j.=-J-L i = i.U c.unhide_horiz()  La recherche Dans la procédure de recherche, on parcourt tout simplement toutes les colonnes pour trouver la colonne qui minimise le nombre d’entrées, donc de compteur S minimal. On pourrait accélérer les choses en utilisant une file de priorité pour les colonnes, mais la procédure de couverture de colonne serait plus coûteuse. La fonction suivante écrit la solution dans un tableau sol si la fonction renvoie Vrai. 
198 CHAPITRE 15. EXPLORATION EXHAUSTIVE  \def dancing_links(size_universe, sets): | header = Cell(None, None, 0, None) # constr. la structure des cellules | col = [] | j for j in range(size_universe): | Ï col.append(Cell(header, None, 0, None)) for i in range(len(sets)): row = None for j in sets[i]: | col[j].S += 1 # une entrée de plus dans cette col. row = Cell(row, col[j], i, col[j1) sol = [] | if solve(header, sol): | return sol | else: return None  def solve(header, sol): if header.R == header: # instance vide, solution trouvée return True None # chercher colonne la moins couverte header.R hile j != header: if c is None or j.S < c.S: Ï | c=j j = jR | cover(c) # couvrir cette colonne r=c.D # essayer chaque ligne while r != c: | sol .append(r.S) | | j=rR # couvrir éléments dans l’ensemble r while j != r: cover(j.C) | j = jR if solve(header, sol): | return True ! j=rL # restituer | while j != r: | uncover(j.C) | j= j.L sol.pop() r=r.D | | uncover(c) | return False  c j W. 
15.2. COUVERTURE EXACTE f 199  0 0 10 1 ! ù +-0.0 1-0-0 | 0 1°1°6 01 0 1060 F 00989 9 f 0 0 0 0 1 0.00 1 1.0 1  FIGURE 15.4— Une matrice binaire M, en haut son codage et en bas le résultat de la couverture de la colonne 0. Les listes sont circulaires, les liens qui quittent un bord du dessin rentrent par le bord opposé. 
200 CHAPITRE 15. EXPLORATION EXHAUSTIVE  15.3 Sudoku  Plusieurs méthodes naïves parviennent à résoudre efficacement des instances classiques de sudoku (voir figure 15.5). Mais pour des grilles de sudoku de taille 16 x 16, l’algorithme des liens dansants semble être l’outil adapté.  Ficure 15.5— Une grille sudoku. Le butest de remplir les cases libres de sorte que chaque colonne, chaque ligne et chaque bloc 3 x 3 contienne tous les entiers de 1 à 9.  Modélisation Comment modéliser le problème de sudoku comme un problème de couverture exacte ? Il y a 4 types de contraintes. Chaque cellule doit avoir une valeur. Dans chaque ligne de la grille du sudoku, chaque valeur doit apparaître exactement une fois. Idem pour les colonnes et les blocs. Il y a donc quatre types d’éléments dans l’univers : les couples ligne-colonne, les couples ligne-valeur, les couples colonne- valeur et les couples bloc-valeur. Ces éléments constituent l’univers U de l'instance (U, S) du problème de couverture exacte. À présent, les ensembles de S vont constituer les affectations. Ce sont donc des triplets ligne-colonne-valeur. Chaque affectation couvre exactement quatre éléments de l'univers. Cette représentation est agréable au sens qu'’elle fait abstraction des notions de lignes, colonnes, blocs ou valeurs, ce qui donne un problème plus facile à manipuler. Comment coder dans l’instance les valeurs déjà fixées dans la grille de sudoku ? Notre méthode consiste à ajouter un nouvel élément e à l’univers et un nouvel ensemble À à S qui sera le seul ensemble à contenir e. Toute solution doit alors être composée de À. Il suffit ensuite de mettre dans À tous les éléments de l’univers déjà couverts par les affectations initiales.  Codage Les ensembles de l’instance U, S) de couverture exacte correspondent aux affectations, et notre implémentation de l’algorithme des liens dansants renvoie la solution sous forme d’un tableau avec les indices des ensembles sélectionnés. Donc pour retrouver les affectations correspondant aux indices, il faut spécifier un codage. Nous choisissons de coder l’affectation d'une valeur v à la case de la ligne r et colonne c par l’entier 817 + 9c + v (remplacer les coefficients par 256 et 16 pour le cas d'un sudoku 16 x 16). De même, les éléments de l’univers seront codés dans des entiers. Par exemple le couple ligne-colonne r, c sera codé par 9r + c, le couple ligne-valeur r, v par81+9r+v et ainsi de suite. 
15.4. ÉNUMÉRATION DE PERMUTATIONS 201  (N =3 # global constants  # les ensembles def assignation(r, c, v): return r * N4 + c * N2 + v  def row(a): return a // N4 def col(a): return (a // N2) % N2 def val(a): return a % N2 def blk(a): return (row(a) // N) * N + col(a) //N  # les éléments à couvrir  def rc(a): return row(a) * N2 + col(a) \ def rv(a): return row(a) * N2 + val(a) + N4 def cv(a): return col(a) * N2 + val(a) + 2 * N4 def bv(a): return blk(a) * N2 + val(a) + 3 * N4 def sudoku(G): | global N, N2, N4 if len(G) == 16: # for a 16 x 16 sudoku grid N, N2, N4 = 4, 16, 256  e = 4 * N4 univers = e + 1 S = [[rc(a), rv(a), cv(a), bv(a)] for a in range(N4 * N2)] A = [e] for r in range(N2): for c in range(N2): if G[r][c] != 0:  a = assignation(r, c, G[r][e] - 1) A += S[a] sol = dancing_links(univers, S + [A])  L£* sol: for a in sol: if a < len(s): G[row(a)][col(a)] = val(a) + 1 return True else: return False  15.4 Énumération de permutations  Applications Certains problèmes par manque de structure nécessitent une résolu- tion naïve qui teste un par un chaque élément de l’espace des solutions potentielles. Il est alors parfois nécessaire d’explorer toutes les permutations d’un tableau donné. 
202 CHAPITRE 15. EXPLORATION EXHAUSTIVE  Exemple : addition de mots Considérons les énigmes de la forme  SEND + MORE =MONEY  où il faut affecter un chiffre distinct à chaque lettre, de telle sorte que chaque mot devienne un nombre ne débutant pas par un Zéro et que l’équation de l'addition soit correcte. Pour résoudre ce problème par énumération naïve, il suffit de construire un tableau tab = "@@DEMNORSY" composé des lettres du problème et complété avec autant de @ que nécessaire pour être de taille 10. À présent, il y a correspondance entre les permutations du tableau et les affectations de chiffres distincts aux lettres, en faisant correspondre chaque lettre par son rang dans le tableau. La partie intéressante est alors l’énumération de toutes les permutations d’un tableau, et c’est le sujet de cette section.  Définition Étant donné un tableau t de n éléments on souhaite déterminer la per- mutation suivante de t, dans l’ordre lexicographique, ou déterminer que t est déjà maximal.  Observation clé Pour permuter t en un tableau qui soit lexicographiquement le suivant, on aimerait préserver un plus long préfixe et échanger des éléments dans le suffixe seulement.  Algorithme en temps linéaire L'algorithme repose sur trois étapes. La première consiste à trouver le plus grand indice p — appelé pivot — tel que t[p] < t[p + 11. L'idée est que le suffixe de t débutant en p+ 1 est une séquence non croissante et donc déjà lexicographiquement maximale. Donc si un tel pivot n’existe pas, l’algorithme termine en annonçant le caractère maximal de t. À ce moment, il est clair que f[p] doit être augmenté, mais de façon minimale. On cherche alors dans le suffixe un indice s tel que t[s] soit minimal et que [s] > t[p]. Comme p+1 est candidat, un tel indice existe toujours. Après avoir échangé t[p] avec t[s], nous obtenons un tableau qui est lexicographiquement supérieur au tableau initial. Enfin, trier dans l’ordre croissant le suffixe de t, en partant de p + 1, permet d’obtenir la plus petite permutation de # parmi celles de préfixe f[1 … p|.  fableaminitial O- 2 | ..6 5 ? 1 choix pivot 0.2 [} 6.5 > ! échange 2.2 6 5 I. ! inverser p ? 2 J ! »> 6l tableaufmnal. @ ?.2. | | 5.6  Ficure 15.6 — Calcul de la permutation suivante.  Trier dans l’ordre croissant le suffixe revient essentiellement à inverser ses élé- ments car initialement, il est dans l’ordre décroissant. 
15.4 ÉNUMÉRATION DE PERMUTATIONS 203  def next_permutation (tab): n = len(tab) pivot = None # trouver le pivot for i in range(n - 1): if tab[i] < tab[i + 1]: pivot = i if pivot is None: # tab déjà maximal return False for i in range(pivot + 1, n): # déterminer élément à échanger if tab[i] > tab[pivot]: swap = i tab[swap], tab[pivot] = tab[pivot], tab[swap] i = pivot + 1 j=n-1l # inverser suffixe while i < j: tab[i], tab[j] = tab[j], tab[i] i+ l j-—=1 return True  La solution au problème d’addition de mots peut alors être codée de la manière suivante.  def convert(word, ass): retval = 0 for x in word: retval = 10 * retval + ass[x] return retval  def solve_word_addition(S): # renvoie le nombre de solutions n = len(S) letters = sorted(list(set('’.join(S)))) not_zero = ‘ # qui ne doivent pas être 0  for word in S: not_zero += word[6] tab = [’@’] * (10-len(letters)) + letters # permut. lex. minimale count = 0 while True: ass = {tab[i]: i for i in range(10)} # table d’association if tab[0] not in not_zero: sum = - convert(S[n-1], ass) # faire l’addition for word in S[:n-1]: sum += convert(word, ass) if sum == 6: # le compte est bon ? count += 1 if not next_permutation(tab): break return count  Variante : énumération de combinaisons et d’arrangements Une astuce pour énu- mérer les combinaisons k parmi n, c'est-à-dire les parties de {1,..,n} à k éléments, consiste à itérer sur les permutations du masque binaire « k pris, n — k pas pris », c'est- à-dire une liste composée de n — k éléments « 0 » suivis de k éléments « 1 ». Ainsi, ce masque nous permet de sélectionner les éléments conservés dans le sous-ensemble. 
204 CHAPITRE 15. EXPLORATION EXHAUSTIVE  Pour énumérer les arrangements k parmi n, il suffit d'énumérer les permutations des combinaisons k parmi n, ce qui revient à utiliser deux itérations next_permutation imbriquées. Cela donne une autre solution au problème d’addition de mots, en sélectionnant les arrangements de K lettres parmi 10 où k est le nombre de lettres distinctes. Nous allons à présent aborder une technique, déjà mentionnée section 1.6.6 page 28, pour itérer de façon plus astucieuse sur les parties d'un ensemble à n éléments, afin de résoudre une grande variété de problèmes de programmation dy- namique.  15.55 Le compte est bon  Ce problème est issu d’un jeu télévisé connu en France depuis 1972 dans l'émission Des chiffres et des lettres.  Entrée n +1 entiers xp,..., X-1, b pour n petit, disons n < 20.  Sortie Une expression arithmétique la plus proche de b, utilisant chacun des entiers au plus une fois et les opérations +, -, * et / autant de fois que nécessaire. La soustrac- tion n’est autorisée que si son résultat est positif et la division n’est autorisée que si son résultat est sans reste.  Algorithme en O(3”) par exploration exhaustive et programmation dynamique. Dans un dictionnaire E, nous associons! à S C {1,...,n — 1} toutes les valeurs attei- gnables en utilisant chaque entrée x; pour : € S au plus une fois. Concrètement, E[S] est à son tour un dictionnaire qui associe à chaque valeur atteignable l’expression correspondante. Par exemple pour x = (3,4,1,8) et S = {0,1}, le dictionnaire E[S] contient les couples composés d’une clé x et d'une valeur e, où e est une expression formée des entrées xy = 3,x; = 4 et x la valeur de e. Donc E[s] contient les couples 1+—> 4—-3,3+— 3,4—47+—3+412+—3+4, Pour calculer E[S], on boucle sur toutes les partitions de S en deux ensembles non vides L et R. Pour chaque valeur v, dans E[L] atteignable par une expression e, et chaque valeur vr dans E[R] atteignable par une expression er, on peut former de nouvelles valeurs à stocker dans E[S]. En particulier, 01 + vr est atteinte par l’expression e, + er. La complexité peut être estimée comme suit. Pour chaque cardinal k (;) ensembles S avec |S| = k sont considérés, et pour chaque ensemble S, tous les sous-ensembles L de S sont considérés qui sont au nombre de 2*. Le travail fait pour S et L fixés est constant. La complexité devient E{_, (,)2 = O(3"). Une attention particulière est apportée à l’ordre dans lequel les ensembles S sont traités. En respectant l’ordre croissant de leur cardinal, nous assurons que les ensembles E[L] et E[R] ont déjà été déterminés.  1. À l’exception de S = V, qui est ignoré. 
15.5. LE COMPTE EST BON ' 205  Détail d’implémentation Pour itérer sur toutes les parties de taille k d’un ensemble, nous avons besoin d’une fonction qui les énumère. Notre implémentation est la fonction all_subsets, écrite sous la forme d’un itérateur. Un itérateur en Python est une fonction qui renvoie chaque résultat avec la commande yield au lieu de return, ce qui a pour effet de ne pas interrompre son exécution. La fonction all_subsets(n, k) énumère toutes les parties S C (0,...,n — 1} de cardinal k. Si i est la plus grande valeur de S, alors S s’écrit S’U{i} pour une partie S’ € {0,...,i—1} de cardinal k—1. Cette décomposition est exploitée dans l'implémentation de all_subsets.  def all_subsets(n, card): if card == yield 0 else: for i in range(card - 1, n): for e in all_subsets(i, card - 1): yield e | (1 << i)  \def arithm_expr_target(x, target): n = len(x) expr = {} for i in range(n): expr[1 << i] = {x[i]: str(xz[il)} tout = (1 << n) - 1 for card in range(2, n + 1): for S in all_subsets(n, card): expr[s] = { for L in range(1, S): if L'@'S == L: R = 5.4-L for vL in expr[L]: for vR in expr[R]: eL = expr[L][vL] eR = expr[R] [vR] expr[S][vL] = eL expr[S][vL + vR]  “(%s+%s)" % (eL, eR) expr[S][vL - vR] "(%s-%5)" % (eL, eR) expr[S][vL * vR] "(%s*%5)" % (eL, eR) if vR != 0 and vL % vR == expr[S][vL // vR] = "(%s/%s)" % (eL, eR) # chercher expression la plus proche du but for dist in range(target + 1): for sign in [-1, +1]: val = target + sign * dist if val in expr[tout]: return "%s=*%i" % (expr[tout][val], val) # partie jamais atteinte si x contient des nombres entre 0 et but pass 
Outil de débogage  Si vous êtes bloqués pendant une résolution de problème, parlez à ce canard. Expliquez-lui en détail et précisément votre approche, commentez-lui chaque ligne de votre code, et vous allez trouver votre erreur. 
[2]  [3]  [4]  [6]  [7]  [8]  [9]  Bibliographie  Ravindra K. Ahuja, Thomas L. Magnanti, and James B. Orlin. Network flows : theory, algorithms, and applications. Prentice Hall, 1993.  Helmut Alt, Norbert Blum, Kurt Mehlhorn, and Markus Paul. Computing a maximum cardinality matching in a bipartite graph in time O(n!* m/ logn). Information Processing Letters, 37(4) :237-240, 1991. Bengt Aspvall, Michael F Plass, and Robert Endre Tarjan. À linear-time algorithm  for testing the truth of certain quantified boolean formulas. Information Processing Letters, 8(3) :121-123, 1979.  Thomas H Cormen, Charles E Leiserson, Ronald L Rivest, and Clifford Stein. Algorithmique. Dunod, 2010.  Jack Edmonds and Ellis L Johnson. Matching, Euler tours and the chinese postman. Mathematical programming, 5(1) :88-124, 1973.  Peter M Fenwick. À new data structure for cumulative frequency tables. Soft- ware : Practice and Experience, 24(3) :327—336, 1994.  Michael L Fredman and Robert Endre Tarjan. Fibonacci heaps and their uses in improved network optimization algorithms. Journal of the ACM (JACM), 34(3) :596-615, 1987.  Rüsiné Freivalds. Fast probabilistic algorithms. In Mathematical Foundations of Computer Science 1979, pages 57—69. Springer, 1979.  Harold N Gabow. An efficient implementation of Edmonds’ algorithm for maximum matching on graphs. Journal of the ACM (JACM), 23(2) :221-234, 1976.  [10] Anka Gajentaan and Mark H Overmars. On a class of O(n?) problems in com-  putational geometry. Computational geometry, 5(3) :165-185, 1995.  [11] David Gale and Lloyd S Shapley. College admissions and the stability of mar-  riage. American mathematical monthly, pages 9-15, 1962.  [12] Andrew V Goldberg and Satish Rao. Beyond the flow decomposition barrier.  Journal of the ACM (JACM), 45(5) :783-797, 1998.  [13] Carl Hierholzer and Chr. Wiener. Über die Môglichkeit, einen Linienzug ohne  Wiederholung und ohne Unterbrechung zu umfahren. Mathematische Annalen, 6(1) :30-32, 1873.  [14] John Hopcroft and Robert Tarjan. Algorithm 447 : Efficient algorithms for graph  manipulation. Communications of the ACM, 16(6) :372-378, 1973. 
210 BIBLIOGRAPHIE  [15] T.C. Hu and M.T. Shing. Computation of matrix chain products. part ii. SLAM Journal on Computing, 13(2) :228-251, 1984.  [16] Richard M Karp. A characterization of the minimum cycle mean in a digraph. Discrete mathematics, 23(3) :309-311, 1978.  [17] Richard M. Karp and M.O. Rabin. Efficient randomized pattern-matching algo- rithms. IBM Journal of Research and Development, 31(2) :249-260, March 1987.  [18] Donald E Knuth. Dancing links. arXiv preprint cs/0011047, 2000.  [19] Donald F Knuth, James H Morris, Jr, and Vaughan R Pratt. Fast pattern matching in strings. SIAM journal on computing, 6(2) :323-350, 1977.  [20] S Rao Kosaraju. Fast parallel processing array algorithms for some graph pro- blems (preliminary version). In Proceedings of the eleventh annual ACM symposium on Theory of computing, pages 231-236. ACM, 1979.  [21] Dexter C. Kozen. The Design and Analysis of Algorithms. Springer Verlag, 1992.  [22] Chi-Yuan Lo, Jiff Matousek, and William Steiger. Algorithms for ham-sandwich cuts. Discrete & Computational Geometry, 11(1) :433-452, 1994.  [23] Glenn Manacher. A new linear-time on-line algorithm for finding the smallest initial palindrome of a string. Journal of the ACM (JACM), 22(3) :346-351, 1975.  [24] Sylvain Perifel. Complexité algorithmique. Ellipses, 2014.  [25] Mechthild Stoer and Frank Wagner. A simple min-cut algorithm. Journal of the ACM (JACM), 44(4) :585-591, 1997.  [26] Volker Strassen. Gaussian elimination is not optimal. Numerische Mathematik, 13(4) :354-356, 1969.  [27] Robert Tarjan. Depth-first search and linear graph algorithms. SIAM journal on computing, 1(2) :146-160, 1972.  [28] Esko Ukkonen. Finding approximate patterns in strings. Journal of algorithms, 6(1) :132-137, 1985.  [29] I-Hsuan Yang, Chien-Pin Huang, and Kun-Mao Chao. A fast algorithm for computing a longest common increasing subsequence. Information Processing Letters, 93(5) :249-253, 2005. 
ACM/ICPC, 8 affichage, 15 aire d’un polygone, 163 algorithme hongrois, voir Kuhn-Munkres anagramme, 35 ancêtre commun le plus proche, 149 Andrew, 162 antichaîne, 142 arbre, 145 (quasi) parfait, 19 alternant, 123 couvrant de poids minimal, 153. d’exploration, 194 d’intervalles, 67 de Fenwick, 64 de segments, 61, 64, 150, 168, 174 des plus courts chemins, 77, 106 DFS, 82 lexicographique, 38 syntaxique, 181 arc avant, 82 arc de liaison, 82 arc retour, 82 arrangement, 203  backtracking, 191 balayage, 25, 51, 69, 70, 162, 167, 171, 173, 177 Bellman-Ford, 109 Bézout, 179 BFS, voir parcours en largeur bord d’un mot, 41 brute-force, voir recherche exhaustive  cache, 66 carré maximal, 170 Castor, 9 chaîne de Markov, 184  Index  chaîne en puissance, 44 chemin alternant, 118 augmentant, 118, 131 eulérien, 95 chiffres, voir compte est bon code de Huffman, 146 coefficient binomial, 180 combinaison, 203 combinatoire, 75, 201 complexité, 15, 25 amortie, 17, 42, 171 composante bi-connexe, 81 connexe, 23, 78 fortement connexe, 87 composition de chemins, 103 compte est bon, 204 correcteur orthographique, 38 coupe minimum, 137 planaire, 138 couplage, 117 biparti graphe complet, 26 biparti maximum, 118 mariages stables, 129 minimax, 126 parfait de poids maximal, 121 planaire sans croisement, 127 couverture exacte, 194, 200 crible d’Ératosthène, 181 cycle alternant, 118 couvrant toutes les arêtes, 98 de poids moyen minimal, 98 de poids négatif, 109, 112 de ratio coût/temps minimal, 101 eulérien, 95 hamiltonien, 95, 102 
212  DAG, voir graphe orienté acyclique deque, voir file à deux bouts DFS, voir parcours en profondeur dictionnaire, 18, 36, 169 Dijkstra, 106, 138 Dilworth, 142 Dinic, 134 distance d’édition, 38, 52 doublon, 60  Edmonds-Karp, 133 ensemble représentation par des entiers, 28 entrées-sorties, 12 enveloppe convexe, 162 Euler, 95 exponentiation rapide, 180 expression arithmétique évaluation, 181 itération, 204  facteur, 35 commun, 47 commun maximal, 47 fenêtre glissante, 45, 66 Fenwick, 64 Fibonacci, 27 file, 18 à deux bouts, 18, 104, 105 de priorité, 19, 107, 154 flot maximum, 130 Floyd-Warshall, 110 fonction de hachage, 18 glissante, 45 Ford-Fulkerson, 130 format d'affichage, 15 France-ioi, 9 Freivalds, 14 fusion de listes triées, 59  Gale-Shapley, 129 Gauss-Jordan, 184 géométrie, 161 balayage, 25, 167, 173, 177 intersection, 184 via k-somme, 159 glouton, 26, 59, 86, 129, 146, 153  INDEX  non optimal, 156 Google Code Jam, 8 Graham, 162 graphe, 73 biparti, 117 déconnexion, 80 implicite, 75 orienté acyclique, 51, 142 par niveaux, 134 résiduel, 131 grille, 51, 77, 112, 170, 172  Hierholzer, 95 histogramme, 171 Huffman, 146  I/O, voir entrées-sorties Infini, 11 instance, 7 interval tree, voir arbre d’intervalles intervalles, 67 arbre, 67 couverture, 70 intersection, 26 union, 69 inverse modulo p, 179 itérateur, 66, 205  k-somme, 159 Kadane, 61 Karp, 99 Knuth, 194 Knuth-Morris-Pratt, 40 Kônig, 120, 142 Kosaraju, 90 Kruskal, 153 Kuhn-Munkres, 121  largeur d’un ordre partiel, 142 laser, 191 Levenshtein, 52 liens dansants, 194 liste, 11 chaînée, 74 d’adjacence, 73 doublement chaînée, 194 
INDEX  Manacher, 48 marche aléatoire, 184 mariages stables, 129 Matousek-Lo-Steiger, 128 matrice d’adjacence, 73 mémoïsation, 51 miroir, 191 multiplication de matrices, 180, 188  nombres premiers, 181 NP-complet, 15 NP-difficile, 15  ordre partiel, 142 orientation de 3 points, 161  palindrome, 48 parcours en largeur, 77 en profondeur, 76 parenthésage, 188 partition, 23 2-PARTITION, 158 3-PARTITION, 29 Patricia trie, 38 période, 43, 44 permutation, 201 PGCD, 179 Pick, 164 pile, 17, 76, 171 pivot de Gauss, 184 plage, 59 doublon, 60 minimum, 61, 150, 168 plus grande somme, 61 requêtes de somme, 64 somme, 60, 174 plus court chemin, 103 à poids positifs ou nuls, 106 dans une grille, 51, 112 graphe à poids {0,1}, 105 graphe non pondéré, 77 graphe orienté acyclique, 113 graphe poids positifs ou nuls, 104 poids arbitraires, 109 toutes paires, 110 plus long chemin, 113  213  dans un arbre, 152 plus longue sous-séquence commune, 54 commune croissante, 58 croissante, 55 point d’articulation, 81 points les plus proches, 164 polygone, 163 points sur contour, 163 rectilinéaire, 167 polynomial, 15 pont, 81 postier chinois, 98 power string, voir chaîne en puissance Prim, 154 principe d’optimalité, 103 probabiliste, 14, 164 problème du cargo à vapeur, 101 produit scalaire, 26 programmation dynamique, 27, 43,51, 58, 61, 98, 102, 109, 113, 152, 155, 158, 170, 188, 204 Prologin, 9 Python, 11  réduction, 15 entre couplages et flots, 140 Rabin-Karp, 45 range minimum query, 61, 150 voir aussi arbre de segments recherche bord d’un mot, 41 d’un sous-ensemble, 157 de doublon, 60 de motifs, 40, 45 dichotomique, 29 exhaustive, 191 trichotomique, 30 rectangle, 169 maximal dans une grille, 172 maximal sous histogramme, 171 union, 173 union disjointe, 177 rectilinéaire, 167, 169 relaxation, 109, 111 rendu de monnaie, 156 
214  requête, 23, 64, 67, 149, 173  sac à dos, 155 satisfiabilité, 15 2-SAT, 92 3-SAT, 15 SAT, 15 segment tree, voir arbre de segments somme d’une plage, 60 sous-chaîne, 35 sous-ensemble partition, 158 somme, 157 sous-mot, 35 sous-séquence, 35 stratégie gagnante, 58 structure de données, 17 structure de données dynamique, 23 arbre d’intervalles, 67 arbre de Fenwick, 64 voir aussi arbre de segments SUBSET-SUM, 157 sudoku, 200 système d’équations linéaires, 184  INDEX  T9, 36, 38 table de hachage, voir dictionnaire Tarjan, 87 tas, 19, 146 taxi, 143 théorème du sandwich au jambon, 128 tour gauche, 161 transport, 139 tri, 25 tri topologique, 85, 113 triangle de Pascal, 180 triangulation, 184 trie, voir arbre lexicographique type abstrait, 17  union-find, 23, 80, 153 voyageur de commerce, 102  yield, voir itérateur  Imprimé en Italie par «La Tipografica Varese Srl» Varese