Objectifs du chapitre
À la fin de ce chapitre, vous serez capable de:
- expliquer pourquoi le coût d'un programme dépend autant de la structure de données choisie que de l'algorithme, et chiffrer cet écart sur un cas concret;
- comparer tableau, liste chaînée, pile, file, arbre binaire de recherche et table de hachage par le coût de l'accès, de l'insertion et de la suppression, et choisir celle qui correspond à un motif d'accès donné;
- énoncer ce que garantit et ce que ne garantit pas le « en moyenne» d'une table de hachage;
- définir un graphe, son degré, ses chemins, ses cycles et sa connexité, et choisir entre matrice d'adjacence et listes d'adjacence en calculant la mémoire des deux;
- dérouler un parcours en largeur et un parcours en profondeur sur un graphe donné, et justifier que le parcours en largeur donne les plus courts chemins d'un graphe non pondéré;
- dérouler l'algorithme de Dijkstra sur un graphe pondéré, donner son coût et expliquer pourquoi il exige des poids positifs ou nuls.
Pourquoi la structure décide du coût
Un contrôle d'accès, deux structures, deux mondes
Une salle de travaux pratiques est équipée d'un lecteur de badges. Le fichier des personnes autorisées contient numéros; chaque matin, badges sont présentés et le programme doit, pour chacun, répondre «autorisé» ou «refusé». L'algorithme est trivial: pour chaque badge, chercher son numéro dans le fichier. Le coût, lui, ne l'est pas — il dépend entièrement de la manière dont les numéros sont rangés.
Première version. Les numéros sont dans un tableau, dans l'ordre où ils ont été saisis. Chercher, c'est parcourir. Quand le numéro est présent, on s'arrête en moyenne au milieu; quand il est absent, on va jusqu'au bout. Prenons le cas favorable où tous les badges sont valides: chaque requête coûte en moyenne comparaisons, et la matinée en coûte
soit deux milliards et demi de comparaisons.
Deuxième version. Les numéros sont triés et l'on cherche par dichotomie, la méthode du chapitre 3. Chaque requête coûte au plus comparaisons, soit comparaisons pour la matinée. Le tri initial se paie une fois, pas chaque matin.
Troisième version. Les numéros sont rangés dans une table de hachage. Chaque requête coûte, en moyenne, un petit nombre constant de comparaisons — disons une ou deux. La matinée coûte de l'ordre de à comparaisons.
Traduisons en temps. Supposons — c'est une hypothèse de travail, pas une mesure, et le chapitre 2 explique pourquoi une telle constante n'a de sens que sur une machine donnée — que la machine effectue comparaisons par seconde. La première version prend secondes, la deuxième millisecondes, la troisième un demi-millième de seconde. Le rapport entre la première et la troisième est de : c'est la différence entre une matinée qui bloque le lecteur de badges et une matinée où personne ne remarque qu'un calcul a eu lieu.
Aucun de ces trois programmes n'est «mieux écrit» que les autres. Ils exécutent la même idée. Ce qui change est la structure de données.
Cette séparation est le cœur du chapitre. Le type abstrait «ensemble avec test d'appartenance» admet au moins trois implémentations, et l'écart de coût que nous venons de calculer est entièrement dû à ce choix. Réciproquement, un algorithme n'a pas de coût dans l'absolu: il a un coût une fois qu'on a dit sur quoi il opère.
Tableau et liste chaînée
Le tableau: la mémoire contiguë
La relation (4.1) est une multiplication et une addition: elle ne dépend pas de , ni de , ni de ce que contient le tableau. L'accès à coûte donc , et c'est la seule structure de ce chapitre pour laquelle l'accès par indice est gratuit. C'est aussi ce qui rend possible la dichotomie du chapitre 3: sauter au milieu d'un tableau trié ne coûte rien, sauter au milieu d'une liste chaînée coûte la moitié de la liste.
Le revers est que la contiguïté doit être maintenue. Insérer une valeur à la position oblige à décaler d'une case les éléments qui suivent; supprimer l'élément oblige à décaler les suivants dans l'autre sens. Dans le pire cas — insérer en tête — ce sont déplacements, donc .
Ajouter à la fin est plus subtil. Si le tableau a été alloué avec de la place en réserve, l'ajout est . S'il est plein, il faut allouer un tableau plus grand et tout recopier, ce qui coûte . La stratégie usuelle — celle des list de Python, des ArrayList de Java, des vector de C++ — est de doubler la capacité à chaque saturation. Construire un tableau de éléments par ajouts successifs déclenche alors des recopies de éléments, soit recopies au total pour ajouts: moins de deux recopies par élément, quelle que soit la taille finale. On dit que l'ajout en fin coûte : une opération isolée peut être chère, mais la moyenne sur une longue suite d'opérations est bornée par une constante.
La liste chaînée: la mémoire dispersée
Les cellules n'ont aucune raison d'être voisines en mémoire: elles sont où l'allocateur les a mises. Il n'existe donc pas de formule analogue à (4.1), et atteindre le -ème élément impose de suivre adresses: dans le pire cas.
En échange, une insertion ne déplace rien. Si l'on tient déjà l'adresse de la cellule après laquelle insérer, créer une cellule et réécrire deux adresses suffit: , indépendamment de la longueur de la liste. Même chose pour la suppression, à condition de tenir l'adresse de la cellule précédente — ce que la version doublement chaînée offre gratuitement.
Cette souplesse se paie en mémoire. Une liste de entiers de octets stocke Mo de contenu utile mais dépense, avec un pointeur de octets par cellule, au moins octets par cellule, et après alignement sur la plupart des machines: 16 Mo au lieu de . Le tableau range la même information quatre fois plus compactement — et il la range de façon contiguë, ce qui, sur une machine réelle, favorise les mémoires caches. Nous ne chiffrerons pas cet effet: il dépend du processeur, de la taille des caches et du motif d'accès, et un facteur inventé ici serait une fabrication. Retenez seulement que le facteur constant joue en faveur du tableau et que la notation ne le voit pas.
Le tableau de coûts
Voici les deux structures mises face à face. est le nombre d'éléments; «position connue» signifie qu'on tient déjà l'adresse de la cellule ou l'indice concerné.
| Opération | Tableau | Liste simplement chaînée |
|---|---|---|
| Lire ou écrire le -ème élément | , donc au pire | |
| Chercher une valeur (données non triées) | ||
| Chercher une valeur (données triées) | par dichotomie | : la dichotomie est impossible |
Un programme maintient un journal d'événements: il ajoute un événement à la fin des milliers de fois par seconde, et relit tout le journal du début à la fin une fois par heure. Il ne fait jamais d'accès par indice. Quelle structure choisir?
Pile et file: le même contenu, deux disciplines
Les deux structures suivantes se distinguent non par leur coût — toutes leurs opérations sont — mais par l'ordre dans lequel elles rendent ce qu'on leur a confié. C'est le premier cas du cours où le choix d'une structure ne change pas la complexité mais le résultat.
Les deux s'implémentent en par opération. Une pile se réalise avec un tableau et un indice de sommet: empiler écrit à l'indice du sommet puis l'incrémente, dépiler le décrémente puis lit. Une file se réalise avec un tableau circulaire et deux indices, tête et queue, qui avancent modulo la capacité; ou avec une liste chaînée munie d'un pointeur de queue, où enfiler et défiler sont deux réécritures d'adresses. Aucune des deux ne parcourt quoi que ce soit.
Appliquons maintenant la même séquence d'opérations aux deux. Nous ajoutons , puis , puis , retirons une valeur, ajoutons , puis retirons trois fois.
La pile rend : elle restitue les valeurs en ordre inverse d'arrivée, en «déballant» ce qui est au-dessus. La file rend : elle sert dans l'ordre d'arrivée. Le contenu confié est identique, l'ordre rendu ne l'est pas. Retenez cette figure: elle est exactement ce qui distinguera, à la fin du chapitre, le parcours en profondeur du parcours en largeur.
Une pile réelle: la pile d'appels
Le chapitre 3 a montré qu'un appel récursif suspend le calcul en cours, lance un nouveau calcul, et reprend là où il en était. Ce «là où il en était» est stocké dans un bloc d'activation (stack frame): les paramètres de l'appel, ses variables locales et l'adresse de retour. Ces blocs sont empilés, et la discipline est nécessairement LIFO, car un appel ne peut se terminer qu'après tous ceux qu'il a lancés.
C'est pourquoi la profondeur de récursion est une consommation de mémoire, et non un détail. Le tri fusion du chapitre 3 sur éléments descend à une profondeur : vingt blocs empilés au maximum — vingt et un si l'on compte le cadre de l'appel initial, comme le fait le chapitre 3 —, rien du tout. Une récursion de profondeur sur , en revanche, empile un million de blocs et provoque le () que tout interpréteur signale. La pile n'est pas une métaphore pédagogique: c'est une zone mémoire de taille fixée, et le message d'erreur dit qu'elle est pleine.
Une file réelle: la file d'impression
Une imprimante partagée reçoit des travaux de plusieurs postes et ne peut en traiter qu'un à la fois. Les travaux sont mis en file d'attente et servis dans l'ordre d'arrivée. Le choix du FIFO est ici un choix d'équité, pas d'efficacité: il garantit qu'aucun travail n'attend indéfiniment, ce qu'une pile ne garantirait pas — sous forte charge, un document déposé tôt pourrait rester enfoui sous ceux qui arrivent ensuite et ne jamais sortir. Le même argument vaut pour les files de tâches d'un système d'exploitation, les files de messages entre services, et les tampons de réception du chapitre 10.
Une file sert aussi, sans aucune considération d'équité, à explorer «de proche en proche»: c'est ce que fait le parcours en largeur, et c'est ce qui lui donne sa propriété de plus court chemin.
On applique à une pile la séquence: empiler 1, empiler 2, empiler 3, dépiler, empiler 4, dépiler, dépiler, dépiler. Rangez les quatre valeurs dépilées dans l'ordre où elles sortent.
Glissez les éléments pour les mettre dans le bon ordre
- 4
- 1
- 2
- 3
Arbres et arbres binaires de recherche
Le vocabulaire
Un arbre binaire est un arbre d'arité au plus dans lequel on distingue, pour chaque nœud, un fils gauche et un fils droit (l'un ou l'autre pouvant manquer). Un arbre à nœuds a exactement arêtes, puisque chaque nœud sauf la racine apporte l'arête qui le relie à son père.
Démonstration. Montrons d'abord par récurrence sur que le nombre de nœuds de profondeur est au plus . C'est vrai pour : il y a une seule racine. Si les nœuds de profondeur sont au plus et que chacun a au plus deux fils, les nœuds de profondeur , qui sont exactement les fils des précédents, sont au plus . En sommant sur ,
Donc , soit ; comme est entier, .
Cette borne est le plafond de performance de toute méthode qui descend un arbre binaire en faisant un test par niveau: on ne peut pas espérer moins de tests. Pour , elle donne ; pour , , donc au moins tests.
L'arbre binaire de recherche
La recherche s'écrit en quatre lignes et n'est rien d'autre que la dichotomie du chapitre 3, exécutée sur une structure au lieu d'un intervalle d'indices:
fonction rechercher(x, clé):
tant que x n'est pas vide:
si clé = clé(x): retourner trouvé
si clé < clé(x): x ← fils gauche de x
sinon: x ← fils droit de x
retourner absent
Chaque tour de boucle descend d'un niveau et fait une comparaison. Le nombre de comparaisons est donc au plus , où est la hauteur. L'insertion suit exactement le même chemin et accroche la nouvelle clé à la place vide où la recherche a échoué.
Tout le coût de l'ABR tient donc dans un seul nombre: sa hauteur. Et la hauteur, elle, dépend de l'ordre d'insertion, pas de l'ensemble des clés.
Un dernier point sur l'ABR: le parcours infixe — visiter le sous-arbre gauche, puis le nœud, puis le sous-arbre droit — rend les clés dans l'ordre croissant, quelle que soit la forme de l'arbre. C'est une conséquence immédiate de la propriété d'ordre, et c'est ce qui fait de l'ABR une structure «triée à tout instant» sans que rien n'ait jamais été trié.
Un arbre binaire de recherche parfaitement équilibré contient de clés. Combien de comparaisons une recherche fait-elle au maximum? (Utilisez la borne du théorème 4.1, atteinte par un arbre équilibré: le nombre de comparaisons vaut .)
La table de hachage
L'idée
La dichotomie et l'arbre équilibré descendent à comparaisons. La table de hachage vise plus bas: ne pas chercher du tout. Si l'on savait, à partir de la clé seule, calculer directement la case où elle doit se trouver, la recherche coûterait un calcul et une lecture, sans aucune comparaison de parcours.
L'exemple le plus simple est pour des clés entières. Pour des chaînes de caractères, on combine les codes des caractères par une formule polynomiale; pour des objets, on combine les hachages des champs. La seule exigence de principe est la reproductibilité: la même clé doit toujours donner le même seau.
Les collisions sont inévitables
Il ne s'agit pas d'un défaut de conception que l'on pourrait corriger. Si — et c'est toujours le cas, puisque est l'ensemble de tous les entiers de bits, ou de toutes les chaînes de caractères — alors par le principe des tiroirs, il existe nécessairement deux clés distinctes de même image. Toute table de hachage doit donc prévoir une stratégie de résolution.
La plus lisible est le chaînage séparé (separate chaining): chaque seau contient une liste chaînée, et une clé en collision est simplement ajoutée à la liste de son seau. Chercher consiste alors à calculer , puis à parcourir la liste du seau en comparant. L'autre grande famille est l'adressage ouvert (open addressing), où tout est rangé dans le tableau lui-même: en cas de collision, on essaie le seau suivant (sondage linéaire), ou une suite de seaux déterminée par la clé. Nous nous en tenons au chaînage, qui suffit à faire apparaître les coûts.
La figure 4.2 illustre un point que l'intuition refuse: avec seulement six clés dans sept seaux, une collision s'est déjà produite. Ce n'est pas de la malchance. Sous l'hypothèse d'un hachage uniforme — chaque clé tombe dans chacun des seaux avec probabilité , indépendamment des autres — la probabilité qu'aucune des clés n'entre en collision vaut
de sorte qu'il y a environ de chances d'observer au moins une collision. C'est le calcul du «paradoxe des anniversaires»: les collisions apparaissent dès que le nombre de clés est de l'ordre de , très loin de .
Le coût, et ce qu'il garantit
Une recherche coûte le calcul de , puis le parcours d'une chaîne. Sous l'hypothèse du hachage uniforme, la chaîne visitée contient en moyenne éléments lorsque la clé est absente, et le résultat classique donne comparaisons en moyenne lorsqu'elle est présente. Dans la table de la figure 4.2, , donc environ comparaison pour un échec et pour un succès.
Le point décisif est que ne dépend pas de seul: il dépend du rapport . Si l'on maintient proportionnel à — en doublant et en re-hachant tout dès que dépasse un seuil, typiquement — alors reste borné par une constante, et les trois opérations (recherche, insertion, suppression) coûtent en moyenne. Le re-hachage coûte mais, par le même argument de doublement que pour les tableaux, il s'amortit en par opération.
Le pire cas, lui, est atroce. Si toutes les clés tombent dans le même seau, la table est une liste chaînée de éléments et chaque recherche coûte .
Notons au passage ce qu'une table de hachage perd par rapport à un arbre: l'ordre. L'ABR rend ses clés triées par un parcours infixe et répond aux requêtes par intervalle («toutes les clés entre et »); la table de hachage disperse volontairement les clés voisines et ne sait répondre qu'à «cette clé exacte est-elle là». C'est le troisième compromis du chapitre, après temps contre mémoire et moyenne contre garantie: fonctionnalité contre vitesse.
Une table de hachage à chaînage possède seaux et contient clés. Sous l'hypothèse du hachage uniforme, combien de cellules une recherche infructueuse examine-t-elle en moyenne?
Les graphes
Définitions
Toutes les structures précédentes rangent des valeurs. Un graphe range des relations: ce sont les liens entre les objets qui portent l'information.
Un graphe connexe et sans cycle est un arbre (non enraciné); il vérifie , et c'est le nombre minimal d'arêtes d'un graphe connexe à sommets.
Démonstration. Comptons de deux façons les couples où est une arête incidente au sommet . En groupant par sommet, on obtient . En groupant par arête, chaque arête fournit exactement deux couples, et : on obtient . Les deux comptes sont égaux. Comme est pair et que les degrés pairs contribuent une somme paire, la somme des degrés impairs est paire, donc ils sont en nombre pair.
Le graphe à huit sommets que nous utiliserons dans tout ce qui suit a les arêtes , donc et . Ses degrés valent pour : leur somme fait , conformément au théorème 4.2. Il est connexe, et il contient des cycles (par exemple ).
Deux représentations, deux factures
La matrice occupe cases quel que soit le nombre d'arêtes. Les listes occupent références (ou pour un graphe orienté), plus les têtes de listes. Tout dépend donc de la densité du graphe, c'est-à-dire du rapport entre le nombre d'arêtes présentes et le nombre d'arêtes possibles.
Le coût mémoire n'est pas le seul critère. Les deux représentations n'offrent pas les mêmes opérations au même prix:
| Opération | Matrice d'adjacence | Listes d'adjacence |
|---|---|---|
| Mémoire | ||
| Tester si et sont voisins | ||
| Énumérer les voisins de |
La dernière ligne est celle qui décide dans la plupart des applications: tous les algorithmes de la fin de ce chapitre parcourent le graphe, et ce parcours coûte avec une matrice contre avec des listes. Sur le réseau routier de l'exemple 4.3, c'est contre opérations — un facteur obtenu sans toucher à une ligne de l'algorithme.
Un graphe non orienté a sommets et arêtes. On doit répéter très souvent l'opération «énumérer les voisins d'un sommet». Quelle représentation choisir?
Parcourir un graphe
Un seul algorithme, deux conteneurs
Explorer un graphe depuis un sommet , c'est visiter tous les sommets qu'on peut atteindre, une fois chacun. La difficulté propre aux graphes — absente des arbres — est la présence de cycles: sans précaution, on tourne indéfiniment. Il faut donc marquer les sommets déjà vus.
Le schéma général tient en dix lignes. Un conteneur garde les sommets découverts mais pas encore traités:
parcours(G, s):
marquer s comme découvert
mettre s dans le conteneur
tant que le conteneur n'est pas vide:
v ← retirer un sommet du conteneur
visiter v
pour chaque voisin w de v:
si w n'est pas découvert:
marquer w comme découvert
mettre w dans le conteneur
Deux points de ce code décident de tout le reste, et il faut les lire lentement.
D'abord, un sommet est marqué au moment où on le découvre, c'est-à-dire quand on le met dans le conteneur, et non quand on l'en retire. Un sommet n'entre donc qu'une seule fois dans le conteneur, celui-ci ne contient jamais de doublon, et la boucle fait exactement retraits. Marquer au retrait plutôt qu'à la découverte donne un algorithme différent — nous y revenons plus bas, car c'est un piège classique.
Ensuite, l'ordre dans lequel on énumère les voisins décide de l'ordre de visite. Nous conviendrons que le plus petit voisin est traité en premier. Pour une file, il suffit d'énumérer les voisins dans l'ordre alphabétique. Pour une pile, le dernier entré étant le premier sorti, il faut les empiler dans l'ordre alphabétique inverse.
Il reste que le code ne dit pas quel sommet retirer. Et c'est là que tout se joue:
- si le conteneur est une file, on retire le plus anciennement découvert: c'est le parcours en largeur (breadth-first search, BFS), qui explore le graphe par couches de distance croissante;
- si le conteneur est une pile, on retire le plus récemment découvert: c'est le parcours en profondeur (depth-first search, DFS), qui s'enfonce le long d'un chemin jusqu'à l'impasse avant de revenir sur ses pas.
Une file au lieu d'une pile, et rien d'autre. La figure 4.1 avait montré que ces deux disciplines rendent des ordres différents; nous allons voir ce que cette différence produit sur un graphe.
Les deux parcours sur le même graphe
Déroulons les deux sur notre graphe à huit sommets, depuis , avec la convention posée plus haut: le plus petit voisin est traité en premier, et un sommet est marqué dès sa découverte (cette convention ne change rien aux propriétés, mais sans elle le résultat n'est pas déterminé). Les traces ci-dessous sont celles que produit l'exécution du schéma, à la ligne près.
Parcours en largeur, avec la file après chaque visite:
| Étape | Sommet visité | File après la visite | Distance depuis A |
|---|---|---|---|
| 1 | A | B, C, D | 0 |
| 2 | B | C, D, E | 1 |
| 3 | C | D, E, F | 1 |
| 4 | D | E, F, H | 1 |
| 5 | E | F, H, G | 2 |
| 6 | F | H, G | 2 |
| 7 | H | G | 2 |
| 8 | G | — | 3 |
L'ordre de visite est . On lit dans la colonne de droite la structure en couches: d'abord , puis les trois voisins de , puis les trois sommets à distance , puis à distance .
Parcours en profondeur, avec la pile après chaque visite (sommet de pile à gauche):
| Étape | Sommet visité | Pile après la visite |
|---|---|---|
| 1 | A | B, C, D |
| 2 | B | E, C, D |
| 3 | E | G, C, D |
| 4 | G | F, H, C, D |
| 5 | F | H, C, D |
| 6 | H | C, D |
| 7 | C | D |
| 8 | D | — |
L'ordre de visite est . Rien de commun avec le précédent au-delà du premier sommet. Suivez le mécanisme: dépose , et dans la pile, mais seul est repris aussitôt; dépose , qui passe devant et ; dépose , qui passe devant à son tour. En quatre étapes le parcours est descendu au fond du graphe, en , le sommet le plus éloigné de — alors que et , voisins immédiats de , attendent au fond de la pile et ne seront servis qu'en dernier. C'est exactement le comportement «plonger d'abord, revenir ensuite», et c'est le contraire de la couche par couche du parcours en largeur.
Les deux parcours coûtent la même chose. Parce qu'un sommet est marqué dès sa découverte, il entre exactement une fois dans le conteneur et en sort exactement une fois: pour cette partie, sans aucun doublon. Chaque arête est examinée exactement deux fois, une fois depuis et une fois depuis , donc pour l'exploration des listes. Au total avec des listes d'adjacence — et avec une matrice, puisque énumérer les voisins d'un sommet y coûte . Sur notre graphe, chacune des deux exécutions a fait extractions et examens de voisins.
Le même graphe, le même code: seul le conteneur change. Une file donne le parcours en largeur, une pile le parcours en profondeur. Un sommet est marqué dès qu'il est découvert, et le plus petit voisin est toujours traité en premier.
Pourquoi le parcours en largeur donne les plus courts chemins
Notons la distance de à : le nombre minimal d'arêtes d'un chemin de à , et si n'est pas atteignable. Le parcours en largeur, si on lui fait mémoriser au moment où il découvre depuis , calcule exactement ces distances.
Démonstration. Établissons d'abord deux propriétés.
(i) pour tout découvert. Par récurrence sur l'ordre de découverte. On a . Si est découvert depuis , alors par hypothèse de récurrence; or l'arête prolonge un chemin de longueur en un chemin de longueur menant à , donc .
(ii) La file est monotone. À tout instant, si la file contient de la tête vers la queue, alors . C'est vrai au départ (la file ne contient que ). Un défilement retire et ne peut que resserrer les inégalités. Un enfilement ajoute un avec , où est le sommet en cours de traitement, c'est-à-dire celui qui vient d'être défilé: la file avant l'ajout vérifie , donc ajouter en queue conserve la croissance, et l'écart entre la tête et la queue reste au plus . les sommets sortent de la file par valeurs de non décroissantes.
(iii) . Par récurrence sur . Pour , et . Supposons la propriété acquise pour tous les sommets à distance , et soit à distance . Il existe un sommet avec et . Par hypothèse de récurrence , et est défilé à un certain moment. Deux cas. Ou bien n'est pas encore découvert quand est traité: il l'est alors depuis , et . Ou bien a été découvert plus tôt, depuis un sommet défilé avant ; par (ii), , donc .
En combinant (i) et (iii), . Enfin, le chemin obtenu en remontant de à son découvreur, puis au découvreur de celui-ci, etc., a une longueur égale à par construction, donc minimale.
Le point de la preuve est la monotonie (ii): c'est la file, et elle seule, qui garantit que l'on n'atteint jamais un sommet de la couche avant d'avoir épuisé la couche . Remplacez-la par une pile et la conclusion tombe: sur notre graphe, le parcours en profondeur découvre depuis , donc par le chemin de longueur , et lui attribuerait alors que (le chemin ). Le sommet est logé à la même enseigne. Le parcours en profondeur n'est donc un algorithme de plus court chemin, et l'erreur qu'il commet peut être arbitrairement grande.
Chacun a son domaine. Le parcours en largeur sert aux plus courts chemins non pondérés, au calcul des composantes connexes, et au test de bipartition d'un graphe (colorier les couches alternativement). Le parcours en profondeur sert à la détection de cycles, au tri topologique d'un graphe orienté acyclique — ordonner des tâches soumises à des contraintes de précédence, comme les dépendances entre paquets logiciels —, à la recherche de composantes fortement connexes, et à l'exploration exhaustive d'un espace de solutions, où l'on veut aller au fond d'une branche avant d'en essayer une autre.
Plus courts chemins pondérés: Dijkstra
Le problème change de nature
Quand les arêtes portent des poids — des kilomètres, des minutes, des francs, une probabilité de perte —, le plus court chemin n'est plus celui qui a le moins d'arêtes. Un détour par trois petites arêtes peut battre une grande arête directe, et le parcours en largeur, qui ne compte que les arêtes, donne une réponse fausse.
L'algorithme de Dijkstra (1959) résout ce problème pour des poids positifs ou nuls. Il maintient pour chaque sommet une distance provisoire — le poids du meilleur chemin connu jusqu'ici, initialisé à sauf — et un ensemble de sommets définitifs, dont la distance ne bougera plus.
dijkstra(G, w, s):
d[s] ← 0; d[v] ← +infini pour tout autre v
définitifs ← ensemble vide
tant qu'il reste un sommet non définitif de d fini:
u ← un sommet non définitif de d[u] minimal
ajouter u aux définitifs
pour chaque voisin v de u non définitif:
si d[u] + w(u, v) < d[v]: # relâchement
d[v] ← d[u] + w(u, v)
père[v] ← u
L'opération centrale — comparer à et améliorer si c'est mieux — s'appelle le relâchement de l'arête. Le reste de l'algorithme n'est qu'une règle d'ordre: relâcher les arêtes en partant chaque fois du sommet non définitif le plus proche.
Une exécution complète
Prenons un réseau fictif de six localités à , les poids étant des distances routières en kilomètres. Les valeurs ci-dessous sont inventées pour l'exemple; elles ne décrivent aucun réseau réel.
Le tableau ci-dessous est celui de l'exécution, sommet par sommet; chaque ligne donne le sommet rendu définitif et l'état du tableau après les relâchements de cette étape.
| Étape | Sommet rendu définitif | d[A] | d[B] | d[C] | d[D] | d[E] | d[F] |
|---|---|---|---|---|---|---|---|
| Initial | — | 0 | ∞ | ∞ | ∞ | ∞ | ∞ |
| 1 | A (0) | 0 | 4 | 2 | ∞ | ∞ | ∞ |
| 2 | C (2) | 0 | 3 | 2 | 10 | ∞ | ∞ |
| 3 | B (3) | 0 | 3 | 2 | 8 | 13 | ∞ |
| 4 | D (8) | 0 | 3 | 2 | 8 | 10 | 14 |
| 5 | E (10) | 0 | 3 | 2 | 8 | 10 | 13 |
| 6 | F (13) | 0 | 3 | 2 | 8 | 10 | 13 |
Les valeurs en gras sont celles qu'un relâchement a améliorées après avoir déjà été écrites. Suivez : il vaut d'abord (par , soit ), puis (par , soit ). Et vaut (par ) avant de tomber à (par ). Une distance provisoire n'est pas une distance: elle est le meilleur chemin , et elle peut être améliorée tant que le sommet n'est pas définitif.
Les distances finales sont , , , , , , et le chemin optimal vers est , dont on vérifie le poids: . Remarquez qu'il emprunte arêtes alors que est à trois arêtes de par , dont le poids est . Le plus court chemin pondéré et le plus court chemin en nombre d'arêtes ne coïncident pas.
Notez aussi que les sommets deviennent définitifs par distances croissantes: . Ce n'est pas un hasard, c'est le cœur de la preuve.
Le coût
Chaque sommet est rendu définitif une fois et chaque arête est relâchée au plus une fois par extrémité, donc (ou ) relâchements en tout. Reste à extraire le minimum fois.
- Avec un simple tableau, chaque extraction balaie les sommets: pour les extractions, pour les relâchements, soit au total, puisque . C'est le bon choix pour un graphe dense.
- Avec une file de priorité implémentée par un tas binaire () — une structure qui rend le minimum et l'insère en —, on obtient . C'est le bon choix pour un graphe creux: sur le réseau routier de l'exemple 4.3, contre .
Là encore, l'algorithme n'a pas changé: c'est la structure choisie pour «prendre le plus petit» qui décide de trois ordres de grandeur.
Pourquoi les poids doivent être positifs ou nuls
Sur le réseau fictif ci-dessus (, , , , , , , , ), rangez les sommets dans l'ordre où l'algorithme de Dijkstra lancé depuis les rend définitifs.
Glissez les éléments pour les mettre dans le bon ordre
- F
- C
- E
- D
- A
- B
Une application: le graphe social
Terminons par un graphe qu'aucune matrice ne peut contenir, et faisons les comptes.
Considérons un réseau social hypothétique de comptes où chaque compte a en moyenne liens réciproques. Ces deux nombres sont des hypothèses de travail choisies pour l'ordre de grandeur, pas des chiffres mesurés sur une plateforme réelle. Le nombre d'arêtes est alors , et la densité vaut .
La matrice d'adjacence est impossible. Elle compte cases. Même comprimée à un bit par case, elle occuperait octets, soit 125 pétaoctets — l'ordre de grandeur d'un centre de données entier, pour un graphe dont on a dit qu'il était vide à .
Les listes d'adjacence tiennent. Il faut références de octets, plus têtes de octets, soit octets, environ : quelques disques. Le rapport entre les deux représentations est de .
Le parcours suit. Un parcours en largeur complet coûte opérations. À l'hypothèse de travail de opérations par seconde et par cœur, cela fait secondes, soit environ 17 minutes sur un seul cœur. Le même parcours sur une matrice d'adjacence coûterait opérations, soit secondes: . Le choix de la représentation ne fait pas ici la différence entre lent et rapide; il fait la différence entre faisable et impossible.
Et les distances? Le parcours en largeur donne la distance de n'importe quel compte à n'importe quel autre. De combien de couches a-t-on besoin pour atteindre tout le monde? Majorons: depuis un sommet de degré , la couche contient au plus sommets, et chaque sommet d'une couche apporte au plus nouveaux sommets à la suivante. Le nombre de sommets à distance au plus est donc borné par
Avec , cette borne vaut pour , pour , pour et pour : elle dépasse le milliard de comptes dès .
Le même appareil décrit d'autres objets. Le web est un graphe orienté dont les sommets sont les pages et les arcs les hyperliens; il est creux (une page porte quelques dizaines de liens, pas des milliards) et il se parcourt par les mêmes algorithmes — un robot d'indexation est littéralement un parcours de graphe, en largeur si l'on veut couvrir un site avant de s'enfoncer, en profondeur sinon. Un réseau de transport est un graphe pondéré dont les poids sont des temps de trajet, et le calculateur d'itinéraires est un Dijkstra — ou l'une de ses variantes accélérées, qui ne changent pas le principe. Un graphe de dépendances entre paquets logiciels est un graphe orienté que l'on trie topologiquement par un parcours en profondeur, et dont un cycle signale une dépendance circulaire impossible à satisfaire.
Synthèse
- Le coût d'un programme dépend autant de la structure de données que de l'algorithme: la même recherche répétée coûte milliards de comparaisons dans un tableau non trié, par dichotomie, et quelques dizaines de milliers dans une table de hachage. Choisir une structure, c'est fixer le coût de chaque opération avant d'écrire la première ligne.
- Le tableau donne l'accès par indice en et paie l'insertion en ; la liste chaînée fait exactement l'inverse. Il n'existe pas de meilleure structure, seulement une meilleure adaptation à un motif d'accès — et la bonne question devant un problème est «quelles opérations, combien de fois chacune».
- Pile et file ont les mêmes coûts, toutes leurs opérations étant , mais pas le même ordre de sortie: sur la même séquence, la pile rend et la file . Cette seule différence sépare la pile d'appels d'une file d'attente, et le parcours en profondeur du parcours en largeur.
Un programme insère éléments en tête d'une suite, puis la parcourt une seule fois du début à la fin. Quelle structure minimise le nombre total d'opérations élémentaires?
Exercices
Vous pouvez afficher le corrigé directement sous chaque énoncé après avoir cherché la solution.
Pour chacun des trois usages suivants, dites quelle structure vous choisissez parmi tableau dynamique, liste doublement chaînée, arbre binaire de recherche équilibré et table de hachage, et justifiez par un compte d'opérations, pas par une impression.
- Un annuaire de collaborateurs interrogé fois par jour par numéro d'identification exact, sans jamais d'ajout ni de suppression en cours de journée.
- Une liste de lecture musicale dans laquelle l'utilisateur insère et retire des morceaux à des positions arbitraires qu'il vient de sélectionner à l'écran, et qu'il parcourt ensuite de proche en proche.
- Un journal de températures horodatées dans lequel on veut, à tout moment, obtenir toutes les mesures d'un intervalle de temps donné.
Solution
La notation postfixée (ou polonaise inverse) écrit les opérandes avant l'opérateur: 3 4 + vaut . Elle s'évalue avec une pile: on lit les symboles de gauche à droite; un nombre est empilé; un opérateur dépile ses deux opérandes, calcule, et empile le résultat.
- Évaluez
3 4 + 2 * 7 -en donnant l'état de la pile après chaque symbole. - Pourquoi faut-il faire attention à l'ordre des deux dépilements pour une soustraction ou une division?
- Que se passe-t-il si l'expression est mal formée, par exemple
3 4 + +? Quel test simple permet de le détecter? - Quelle est la hauteur maximale atteinte par la pile lors de l'évaluation de
3 4 + 2 * 7 -, et que mesure cette hauteur?
Solution
1. Voici la trace, produite par l'exécution:
| Symbole | Action | Pile après (fond → sommet) |
|---|---|---|
3 | empiler 3 | 3 |
4 | empiler 4 | 3, 4 |
+ | dépiler 4 et 3, empiler |
- Insérez les clés dans cet ordre dans un ABR vide. Dessinez l'arbre, donnez sa hauteur, et le nombre de comparaisons qu'il a fallu pour les sept insertions.
- Faites de même avec le même ensemble de clés inséré dans l'ordre , puis dans l'ordre .
Soit le graphe non orienté à six sommets et d'arêtes .
- Montrez que si toutes les arêtes ont le poids , l'algorithme de Dijkstra rend les mêmes distances que le parcours en largeur. Les deux algorithmes rendent-ils les sommets définitifs dans le même ordre?
- Démontrez que, dans l'exécution de Dijkstra sur un graphe à poids positifs ou nuls, la suite des valeurs des sommets successivement rendus définitifs est croissante au sens large.
- On propose la «correction» suivante pour traiter les poids négatifs: ajouter une même constante à tous les poids pour les rendre positifs, appliquer Dijkstra, puis retrancher. Montrez sur un exemple que cette idée est fausse.
- Sur le réseau fictif du cours (, , , , , , , , ), que devient la distance de à si l'arête passe de à ?
Références
- Cormen, T. H., Leiserson, C. E., Rivest, R. L. et Stein, C., Introduction to Algorithms, 4e éd., MIT Press, Cambridge, chap. 10 à 12 (structures élémentaires, tables de hachage, arbres de recherche) et 20 à 22 (graphes, parcours, plus courts chemins).
- Sedgewick, R. et Wayne, K., Algorithms, 4e éd., Addison-Wesley, Boston, chap. 1, 3 et 4.
- Abelson, H., Ledeen, K. et Lewis, H., Blown to Bits, Addison-Wesley, Boston (le web comme graphe et ce que l'on en déduit).
- Dijkstra, E. W., «A Note on Two Problems in Connexion with Graphs», Numerische Mathematik, vol. 1, 1959, p. 269–271 (l'article original, long de trois pages).
- Sipser, M., Introduction to the Theory of Computation, 3e éd., Cengage, Boston, chap. 0 (le vocabulaire des graphes, énoncé sans détour).
- Polycopiés du cours ICC de l'EPFL, partie «structures de données et graphes».