Skip to main content Informatique en prépa - Lycée Malherbe 

14- Parcours et plus courts chemins

Dans ce chapitre, on utilise les structures de données du chapitre précédent : piles, files et files de priorité.

I. Les parcours

a) Anatomie d'un parcours

On a besoin d'une structure de données pour stocker des nœuds :

Parcours d'un graphe
Entrée : G un graphe, s un élément de départ
1.Ajouter s dans la structure
2.Tant que la structure n'est pas vide
3.Prendre x un élément de la structure
4.(...)
5.Ajouter ses voisins non visités à la structure.

b) Parcours en profondeur : la pile

Voici une implémentation en Python, qui affiche simplement les sommets dans l'ordre. On suppose que le graphe est implémenté par liste d'adjacence :

a a b b a--b c c b--c c--a d d d--b
Python

Loading editor...

G = { "a": ["b", "c"],
      "b": ["d", "a", "c"],
      "d": ["b"],
      "c": ["b", "a"]}
Python
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Une première idée serait d'écrire :

def parcours_profondeur(G, d):
    pile = [d]
    while len(pile) > 0:
        x = pile.pop()
        print(x)
        for voisin in G[x]:
            pile.append(voisin)

Mais ici, on va ajouter les mêmes sommets à la pile plusieurs fois, et même sans fin s'il y a des cycles ! Il faut retenir les sommets déjà vus :

Python

Loading editor...

def parcours_profondeur(G, d):
    pile = [d]
    deja_vu = {d: True}
    while len(pile) > 0:
        x = pile.pop()
        print(x)
        # On ajoute les voisins à la pile
        for voisin in G[x]:
            if voisin not in deja_vu:
                pile.append(voisin)
                #Evite d'ajouter les mêmes sommets à la pile plusieurs fois
                deja_vu[voisin] = True
Python

Loading editor...

parcours_profondeur(G, "a")

Pour visualiser le parcours, ordre_affiche récupère la liste des sommets affichés par la fonction, et animer_parcours les allume un par un :

Python
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Depuis a, ce parcours visite a, c, b puis d. En rouge, les arêtes par lesquelles chaque sommet a été découvert (l'arborescence du parcours) :

a a b b a--b c c b--c c--a d d d--b

Version récursive :

Python

Loading editor...

def parcours_profondeur(G, d, deja_vu):
    # premier appel : parcours_profondeur(G, d, {d: True})
    print(d)
    for voisin in G[d]:
        if voisin not in deja_vu:
            deja_vu[voisin] = True
            parcours_profondeur(G, voisin, deja_vu)
Python
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

c) Parcours en largeur : la file

Python

Loading editor...

from collections import deque

def parcours_largeur(G, d):
    file = deque()
    file.appendleft(d)
    deja_vu = {d: True}
    while len(file) > 0:
        x = file.pop()
        print(x)
        # On ajoute les voisins à la file
        for voisin in G[x]:
            if voisin not in deja_vu:
                file.appendleft(voisin)
                #Evite d'ajouter les mêmes sommets à la file plusieurs fois
                deja_vu[voisin] = True
Python
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Sur un graphe un peu plus grand, la différence entre les deux parcours se voit mieux :

Python
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Depuis a, ce parcours visite a, b, c puis d. En rouge, les arêtes par lesquelles chaque sommet a été découvert :

a a b b a--b c c b--c c--a d d d--b

Pour un crawler web, quel parcours utiliseriez-vous ? Et pour trouver la sortie d'un labyrinthe ?

II. Plus court chemin

Sur un graphe non pondéré, le parcours en largeur permet de trouver les plus courts chemins depuis un sommet : on augmente la profondeur graduellement (d'abord les sommets à distance 1, puis 2, etc.).

Mais sur un graphe pondéré (un plan, avec des autoroutes, des routes de campagne, etc.), pouvez-vous faire un parcours qui permet de trouver le plus court chemin ?

Quel est le plus court chemin entre e (en rouge) et c (en bleu) ?

e e a a e--a 2 d d e--d 5 c c c--a 7 b b a--b 1 b--c 5 d--c 2 d--a 3 d--b 1

Le chemin e, a, b, d, c, de poids :

e e a a e--a 2 d d e--d 5 c c c--a 7 b b a--b 1 b--c 5 d--c 2 d--a 3 d--b 1

Idée : plutôt que d'augmenter la profondeur, on augmente la pondération.

a) Algorithme de Dijkstra

Algorithme de Dijkstra
Entrée : G un graphe connexe, d un sommet de départ
Sortie : La distance de d à chaque sommet du graphe.
1.Soit F une file de priorité vide
2.Soit V un dictionnaire
3.Ajouter (d, 0) à F
4.Tant que F n'est pas vide
5.Prendre (x, l) le couple minimal de F
6.Ajouter (x, l) à V
7.Pour chaque arête (x, y, ρ)
8.Si y n'est pas dans V
9.Si y n'est pas dans F
10.ajouter (y, l + ρ) à F
11.Sinon
12.mettre à jour le poids de y dans F, au minimum
entre le poids actuel et l + ρ
13.Renvoyer V

Déroulez l'algorithme sur l'exemple suivant, depuis le sommet a.

a a b b a->b 1 c c a->c 4 b->c 2 d d b->d 3 e e c->e 2 d->e 5 e->c 1

Les sommets sont ajoutés à V dans l'ordre : a (distance 0), b (1), c (3), d (4), e (5).

Quelle est la complexité de l'algorithme ?

, avec une file de priorité dont les opérations sont en .

En Python :

Python

Loading editor...

import math

def dijkstra(G, d):
    """Implemente l'algorithme de Dijkstra sur un graphe par liste d'adjacence.
    Renvoie un dictionnaire qui à chaque sommet associe sa distance à d.
    On utilise une implémentation naïve de file de priorité.
    """
    noeuds = {s: math.inf for s in G.keys()}
    noeuds[d] = 0
    V = {}
    while len(noeuds) > 0:
        # On trouve le plus petit élément de noeuds
        sommet_min = None
        distance_min = math.inf
        for (sommet, distance) in noeuds.items():
            if distance < distance_min:
                distance_min = distance
                sommet_min = sommet
        # Si le sommet minimum est None, c'est qu'on a parcouru toute la composante connexe
        # On peut donc renvoyer le dictionnaire;
        if sommet_min is None:
            return V
        # On le supprime de la liste des noeuds et on l'ajoute au dictionnaire résultat.
        del noeuds[sommet_min]
        V[sommet_min] = distance_min

        # On met à jour la distance des voisins
        for (voisin, poids) in G[sommet_min]:
            if voisin not in V:
                noeuds[voisin] = min(noeuds[voisin], distance_min + poids)
    return V

Sur le graphe de l'exemple : le dictionnaire renvoyé retient les sommets dans l'ordre où ils ont été traités, qu'on peut donc animer.

Python
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Quelle est la complexité de cette implémentation ?

: à chaque tour de boucle, on cherche le minimum parmi les sommets restants.

Pour visualiser ces algorithmes : visualgo.net.

b) A*

L'idée de l'algorithme A* est d'utiliser une heuristique, c'est-à-dire une indication sur le résultat spécifique au problème traité, souvent facile à calculer mais imprécise.

En l'occurrence, cette heuristique est une indication sur la distance minimum qu'il reste pour parvenir au résultat. Pour que notre algorithme reste correct, l'heuristique doit toujours être plus petite que la vraie distance !

Exemple : dans le cas d'une carte, on peut prendre la distance "à vol d'oiseau" ! Quel que soit le chemin emprunté, on ne pourra pas aller plus vite.

Supposons qu'on ait une fonction qui code l'heuristique. Dans ce cas, on ne va plus prendre le sommet de distance à l'origine minimale, mais celui qui minimise ! C'est-à-dire celui qui a le potentiel d'être sur le chemin le plus court.

La complexité de A* n'est pas meilleure que celle de Dijkstra dans le pire cas. Mais dans la majorité des cas réels, cela va beaucoup plus vite !

Comparez les deux algorithmes sur une grille, où l'on se déplace d'une case à une voisine (en bleu, les cases explorées, dans l'ordre où elles sortent de la file de priorité) : A* se dirige vers la cible, alors que Dijkstra explore dans toutes les directions.

1 / 110

0 cases explorées sur 108
Cliquez sur les cases pour ajouter ou enlever des murs.

Exercices

Exercice 1

Écrire une fonction accessible(G, d, a) qui renvoie True s'il existe un chemin de d à a dans le graphe G (dictionnaire de listes d'adjacence).

Python

Loading editor...

On fait un parcours depuis d, et on regarde si a a été vu :

def accessible(G, d, a):
    vus = {d: True}
    pile = [d]
    while len(pile) > 0:
        x = pile.pop()
        for v in G[x]:
            if v not in vus:
                vus[v] = True
                pile.append(v)
    return a in vus
Exercice 2

Écrire une fonction distances(G, d) qui renvoie un dictionnaire associant à chaque sommet accessible depuis d sa distance à d (en nombre d'arêtes), avec un parcours en largeur.

Python

Loading editor...

def distances(G, d):
    dist = {d: 0}
    file = [d]
    while len(file) > 0:
        x = file.pop(0)
        for v in G[x]:
            if v not in dist:
                dist[v] = dist[x] + 1
                file.append(v)
    return dist
Exercice 3

Dérouler l'algorithme de Dijkstra à la main sur le graphe G2, depuis a, en donnant le contenu de la file de priorité F et du dictionnaire V à chaque étape. Vérifier avec la fonction du cours.

On peut afficher les étapes en ajoutant print(F, V) au début de la boucle while de la fonction dijkstra.

Exercice 4

Écrire une fonction est_connexe(G) qui teste si un graphe non orienté (dictionnaire de listes d'adjacence, non vide) est connexe.

Python

Loading editor...

Un graphe est connexe si un parcours depuis un sommet quelconque atteint tous les sommets :

def est_connexe(G):
    d = next(iter(G))   # un sommet quelconque
    vus = {d: True}
    pile = [d]
    while len(pile) > 0:
        x = pile.pop()
        for v in G[x]:
            if v not in vus:
                vus[v] = True
                pile.append(v)
    return len(vus) == len(G)
Exercice 5

Modifier le parcours en largeur pour renvoyer un plus court chemin (en nombre d'arêtes) entre deux sommets d et a, sous forme de liste de sommets.

Python

Loading editor...

On retient le père de chaque sommet, puis on remonte depuis a :

def plus_court_chemin(G, d, a):
    pere = {d: None}
    file = [d]
    while len(file) > 0:
        x = file.pop(0)
        for v in G[x]:
            if v not in pere:
                pere[v] = x
                file.append(v)
    if a not in pere:
        return None
    chemin = [a]
    while chemin[-1] != d:
        chemin.append(pere[chemin[-1]])
    return chemin[::-1]
Exercice 6

Pourquoi l'algorithme de Dijkstra ne fonctionne-t-il pas avec des poids négatifs ? Donner un exemple.

Dijkstra considère qu'un sommet extrait de la file a sa distance définitive, ce qui suppose qu'un chemin ne peut pas devenir plus court en passant par d'autres sommets. Exemple : arêtes de poids 1, de poids 2, de poids . Dijkstra fixe à distance 1, alors que le chemin a un poids .