Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Licence CC BY-NC-ND, Thierry Parmentelat

introduction

dans ce TP nous allons

et pour cela nous aurons besoin

comme nous n’avons pas encore étudié les classes, nous allons nous restreindre à utiliser uniquement les types de base de Python - listes, tuples, dictionnaires et ensembles

formalisation

nous nous intéressons aux graphes valués, qu’on peut définir formellement comme un triplet G=(V,E,W)G =(V, E, W), où

familles de problèmes

dans la littérature, les problèmes de plus court chemin participent de plusieurs niveaux de complication, selon qu’on cherche la distance la plus courte

en toute généralité on peut aussi considérer le cas où les poids peuvent être négatifs

pour notre part et dans la suite, on se placera dans le cas usuel où toutes les distances sont strictement positives, et on va se concentrer sur le single pair problem, c’est-à-dire avec une source et une destination spécifiques.

structure de données

pour ce TP, on va se limiter à des sommets qui soient des chaines de caractères

quelles options voyez-vous pour modéliser un graphe par un objet Python ?

liste de listes

matrice

si on veut coder le graphe comme une matrice, on a besoin aussi de garder les noms des sommets

autres idées ?

lecture d’un fichier

la plupart du temps on va aller chercher ces données sur Internet auprès de dépôts de type Open-Data, et sur Internet on ne trouve pas des objets Python (matrice ou liste ou dictionnaire ou ...), on trouve seulement du texte (même quand c’est du HTML ou du XML ou du JSON ou du CSV, c’est toujours du texte, plus ou moins facile à transformer en objets Python)

donc pour pouvoir stocker / échanger les données de type graphe, on a besoin aussi d’un format textuel
c’est quoi un format textuel ? simplement un ensemble de conventions qui décrivent comment on peut écrire un graphe sous forme de texte

dans notre cas, nous allons choisir la forme la plus simple possible :

ce qui donnerait (par exemple) pour notre graphe témoin le fichier (ouvrez-le sous vs-code) data/graph.csv

graph.csv
a,b,7
a,d,9
a,c,14
b,d,10
b,e,15
c,d,2
c,f,9
d,e,11
e,f,6

exo #1: parse_graph

notre premier exercice va donc consister à écrire une fonction qui

mais en fait, on a choisi quoi comme structure de données ?
pour éviter les inconvénients des listes et des matrices, on va représenter un graphe comme

['a', 'b', '12']
12
'a,b,12'
' a,b,12'

à vous de jouer

pour vérifier, inspectez visuellement votre résultat
vérifiez aussi/surtout que les poids sont bien des entiers et pas des chaines

False
False
False

nombre de sommets

exo #2: number_vertices

écrivez une fonction qui retourne le nombre de sommets du graphe

pour vérifier

False

atteignabilité

maintenant que nous avons une structure de données, nous allons pouvoir en faire quelque chose d’utile
le premier algorithme que nous allons voir consiste à calculer l’ensemble des sommets que l’on peut atteindre en partant d’un sommet donné

commençons par voir un exemple

There is no layout engine support for "neato"
 Use one of: dot
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/backend/execute.py:88, in run_check(cmd, input_lines, encoding, quiet, **kwargs)
     87 try:
---> 88     proc.check_returncode()
     89 except subprocess.CalledProcessError as e:

File /usr/lib/python3.14/subprocess.py:509, in CompletedProcess.check_returncode(self)
    508 if self.returncode:
--> 509     raise CalledProcessError(self.returncode, self.args, self.stdout,
    510                              self.stderr)

CalledProcessError: Command '[PosixPath('dot'), '-Kneato', '-Tsvg']' returned non-zero exit status 1.

During handling of the above exception, another exception occurred:

CalledProcessError                        Traceback (most recent call last)
File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/jupyter_integration.py:98, in JupyterIntegration._repr_mimebundle_(self, include, exclude, **_)
     96 include = set(include) if include is not None else {self._jupyter_mimetype}
     97 include -= set(exclude or [])
---> 98 return {mimetype: getattr(self, method_name)()
     99         for mimetype, method_name in MIME_TYPES.items()
    100         if mimetype in include}

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/jupyter_integration.py:112, in JupyterIntegration._repr_image_svg_xml(self)
    110 def _repr_image_svg_xml(self) -> str:
    111     """Return the rendered graph as SVG string."""
--> 112     return self.pipe(format='svg', encoding=SVG_ENCODING)

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/piping.py:104, in Pipe.pipe(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
     55 def pipe(self,
     56          format: typing.Optional[str] = None,
     57          renderer: typing.Optional[str] = None,
   (...)     61          engine: typing.Optional[str] = None,
     62          encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
     63     """Return the source piped through the Graphviz layout command.
     64 
     65     Args:
   (...)    102         '<?xml version='
    103     """
--> 104     return self._pipe_legacy(format,
    105                              renderer=renderer,
    106                              formatter=formatter,
    107                              neato_no_op=neato_no_op,
    108                              quiet=quiet,
    109                              engine=engine,
    110                              encoding=encoding)

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/_tools.py:185, in deprecate_positional_args.<locals>.decorator.<locals>.wrapper(*args, **kwargs)
    177     wanted = ', '.join(f'{name}={value!r}'
    178                        for name, value in deprecated.items())
    179     warnings.warn(f'The signature of {func_name} will be reduced'
    180                   f' to {supported_number} positional arg{s_}{qualification}'
    181                   f' {list(supported)}: pass {wanted} as keyword arg{s_}',
    182                   stacklevel=stacklevel,
    183                   category=category)
--> 185 return func(*args, **kwargs)

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/piping.py:121, in Pipe._pipe_legacy(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
    112 @_tools.deprecate_positional_args(supported_number=1, ignore_arg='self')
    113 def _pipe_legacy(self,
    114                  format: typing.Optional[str] = None,
   (...)    119                  engine: typing.Optional[str] = None,
    120                  encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
--> 121     return self._pipe_future(format,
    122                              renderer=renderer,
    123                              formatter=formatter,
    124                              neato_no_op=neato_no_op,
    125                              quiet=quiet,
    126                              engine=engine,
    127                              encoding=encoding)

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/piping.py:149, in Pipe._pipe_future(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
    146 if encoding is not None:
    147     if codecs.lookup(encoding) is codecs.lookup(self.encoding):
    148         # common case: both stdin and stdout need the same encoding
--> 149         return self._pipe_lines_string(*args, encoding=encoding, **kwargs)
    150     try:
    151         raw = self._pipe_lines(*args, input_encoding=self.encoding, **kwargs)

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/backend/piping.py:212, in pipe_lines_string(engine, format, input_lines, encoding, renderer, formatter, neato_no_op, quiet)
    206 cmd = dot_command.command(engine, format,
    207                           renderer=renderer,
    208                           formatter=formatter,
    209                           neato_no_op=neato_no_op)
    210 kwargs = {'input_lines': input_lines, 'encoding': encoding}
--> 212 proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
    213 return proc.stdout

File /__w/exos-python/exos-python/venv/lib/python3.14/site-packages/graphviz/backend/execute.py:90, in run_check(cmd, input_lines, encoding, quiet, **kwargs)
     88     proc.check_returncode()
     89 except subprocess.CalledProcessError as e:
---> 90     raise CalledProcessError(*e.args)
     92 return proc

CalledProcessError: Command '[PosixPath('dot'), '-Kneato', '-Tsvg']' returned non-zero exit status 1. [stderr: 'There is no layout engine support for "neato"\n Use one of: dot\n']
<graphviz.graphs.Digraph at 0x7f6719748050>
{'a': {'a', 'b', 'c', 'd', 'e', 'f'}, 'b': {'a', 'b', 'c', 'd', 'e', 'f'}, 'd': {'a', 'b', 'c', 'd', 'e', 'f'}, 'c': {'a', 'b', 'c', 'd', 'e', 'f'}, 'e': {'e', 'f'}, 'f': {'e', 'f'}}
en partant de a → {'e', 'c', 'f', 'a', 'd', 'b'}
en partant de b → {'e', 'c', 'f', 'a', 'd', 'b'}
en partant de d → {'e', 'c', 'f', 'a', 'd', 'b'}
en partant de c → {'e', 'c', 'f', 'a', 'd', 'b'}
en partant de e → {'f', 'e'}
en partant de f → {'f', 'e'}

la difficulté

l’anti-loop

si on parlait d’arbres et non pas de graphes, on pourrait s’en sortir très simplement avec un parcours récursif en profondeur

mais ici on a des graphes, avec possiblement des cycles, et donc il faut faire un peu attention, notamment à ne pas boucler à l’infini

quelles méthodes est-ce que vous voyez pour éviter justement de boucler (pour éviter de repasser plusieurs fois au même endroit ?)

indice soyez attentifs à la performance; on veut pouvoir utiliser cet algorithme avec des graphes très gros…

quand est-ce qu’on s’arrête ?

comment fait-on pour décider de s’arrêter ?

ne pas modifier le sujet de la boucle

sans transition, mais c’est sans doute le bon moment pour signaler une limitation de Python, qui est qu’on ne peut pas modifier un objet sur lequel on est en train de faire une boucle

illustration :

OOPS <class 'RuntimeError'> dictionary changed size during iteration
OOPS <class 'RuntimeError'> Set changed size during iteration

exo #3: reachables

je ne vous donne pas davantage d’indices, je vous laisse écrire ceci

pour vérifier

vous pouvez vérifier visuellement en comparant vos résultats avec ceux qu’on a vus dans l’exemple

depuis a → None
depuis b → None
depuis d → None
depuis c → None
depuis e → None
depuis f → None
en partant de a → False
en partant de b → False
en partant de d → False
en partant de c → False
en partant de e → False
en partant de f → False
ERROR since a: found None != {'c', 'f', 'a', 'b', 'd', 'e'}
ERROR since b: found None != {'d', 'f', 'b', 'e'}
ERROR since c: found None != {'c', 'f', 'e', 'd'}
ERROR since d: found None != {'d', 'f', 'e'}
ERROR since e: found None != {'f', 'e'}
ERROR since f: found None != {'f'}

plus courte distance

on va pouvoir aussi calculer le plus court chemin entre deux noeuds d’un graphe
pour cela nous allons utiliser un algorithme très classique, connu sous le nom d’algorithme de Dijkstra

c’est un algorithme très utilisé; lorsque vous demandez à une app de vous calculer un itinéraire par exemple, c’est bien sûr comme ça que c’est calculé, et il y a de fortes chances pour que l’algorithme utilisé soit basé sur Dijkstra; remarquez que ce qu’on a appelé distance jusqu’ici, ça peut être aussi une durée, ou n’importe quoi d’autre bien entendu.

l’idée générale est assez simple :

et du coup si/quand on arrive au sommet d’arrivée, on a forcément trouvé le plus court chemin entre les deux

illustration

voici une illustration de cet algorithme, sur notre graphe témoin, entre les noeuds a et f

l’algorithme

en français :

question

pour les forts

à ce stade si vous êtes relativement confortable avec Python, vous devez pouvoir écrire une fonction qui calcule la distance la plus courte entre deux noeuds du graphe
n’hésitez pas alors à passer directement à la section “exo #4”, quitte à remonter voir les indices ensuite

des indices pour les autres

je décortique un peu pour ceux qui sont moins à l’aise

comment itérer sur le graphe

quelques rappels/astuces qui peuvent servir dans ce contexte :

b → 10 → d
b → 15 → e
s='a' → d='b'
s='a' → d='d'
s='a' → d='c'
s='b' → d='d'
s='b' → d='e'
s='c' → d='d'
s='c' → d='f'
s='d' → d='e'
s='e' → d='f'
True

structure générale de l’algorithme

pour commencer la structure générale de la fonction ressemble à ceci

à ne pas prendre au pied de de la lettre, vous pouvez/devez changer/renommer/faire autrement comme vous le sentez...

def shortest_distance(graph, v1, v2):

    # initialisation
    # on se définit une variable locale à la fonction
    # qui matérialise le marquage

    visited = ...

    # ensuite on fait une boucle jusqu'à ce qu'une certaine condition soit remplie
    # souvenez-vous qu'on peut sortir d'un while avec 'break' - ou aussi 'return' d'ailleurs
    while True:

        # on va calculer les arêtes qui font partie de la bordure
        edges = set()

        # on énumère toutes les arêtes, et on ajoute dans
        # edges celles qui satisfont le critère
        # for ...
        #    for ...
        #      if ...
        #         edges.add(...)
        #  

        # si on n'a aucune arête c'est que c'est raté
        if not edges:
            return

        # sinon on trouve la meilleure
        shortest_length = math.inf
        shortest_vertex = None
        for edge in edges:
            ... # trouver la plus courte
                # et mémoriser le sommet correspondant

        # marquer le sommet correspondant

        # regarder si c'est le sommet 
        if shortest_vertex == v2:
            return ...

exo #4: shortest_distance

vérifications

pour vérifier si votre code fonctionne :

{'a': {'b': 7, 'd': 9, 'c': 14}, 'b': {'d': 10, 'e': 15}, 'c': {'d': 2, 'f': 9}, 'd': {'e': 11}, 'e': {'f': 6}, 'f': {}}
False
False
True

vérification avec un autre graphe en entrée

oops: 'NoneType' object has no attribute 'items'

avec quelques graphes denses

c’est l’occasion de parler un peu de l’instruction assert:

---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[38], line 2
      1 GD2 = parse_graph("data/dense-2.csv")
----> 2 assert shortest_distance(GD2, "1x1", "2x2") == 4

AssertionError: 
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[39], line 2
      1 GD3 = parse_graph("data/dense-3.csv")
----> 2 assert shortest_distance(GD3, "1x1", "3x3") == 6

AssertionError: 
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[40], line 2
      1 GD4 = parse_graph("data/dense-4.csv")
----> 2 assert shortest_distance(GD4, "1x1", "4x4") == 8

AssertionError: 

exo #5 : shortest_path

comment pourriez-vous adapter cet algorithme pour retourner aussi le chemin par lequel il faut passer ?

pour vérifier

un graphe un peu plus réaliste

dans cette section on va se contenter d’utiliser ce qui précède, mais sur un graphe un peu plus gros
on est allé chercher les données dans ce dépôt sur github
il s’agit des relations entre les personnages d’un roman qui se situe dans le monde de Game of Thrones
on a choisi ces données car le graphe est de taille moyenne (71 sommets) mais reste suffisamment petit pour qu’on puisse vaguement le dessiner
remarquez que les données sont issues d’un dépôt 100% Java; le format de données ne dépend pas du tout du langage qu’on utilise pour les traiter, bien entendu

on va profiter de l’occasion pour voir comment aller chercher des données sur Internet

aller chercher et transformer la donnée

'Source,Target,Weight\nAemon,Grenn,5\nAemon,Samwell,31\nAerys,Jaime,18\nAerys,Robert,6\nAerys,Tyrion,5\nAerys,Tywin,8\nAlliser,Mance,5\nAmory,Oberyn,5\nArya,Anguy,11\nArya,Beric,23\nArya,Bran,9\nArya,Brynden,6\nAry'

maintenant le texte de la page Web est dans une variable Python (de type str donc)
il se trouve toutefois que

donc bref, pour ne pas nous compliquer la vie, on va créer un fichier local avec le contenu du texte, moins la première ligne

une autre approche aurait pu être de re-factorer le code de parse_graph, pour permettre le parsing à partir d’une chaine, mais bon on ne va pas se compliquer la vie ici…; en plus ça nous donne une occasion d’utiliser ce qu’on a appris sur la création des fichiers

pour vérifier le contenu, regardez les 5 premières lignes qui devraient être

Aemon,Grenn,5
Aemon,Samwell,31
Aerys,Jaime,18
Aerys,Robert,6
Aerys,Tyrion,5
...

charger le graphe thrones

False

on peut maintenant voir un peu à quoi il ressemble; enfin, si on a graphviz installé, et sinon, eh bien ce n’est pas grave !

oops: 'NoneType' object has no attribute 'items'
too bad: 'NoneType' object has no attribute 'attr'

et maintenant on peut faire des calculs dans ce graphe

atteignabilité

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[50], line 3
      1 # ce personnage semble assez central
      2 
----> 3 len(reachables(thrones, 'Eddard')) == 88

TypeError: object of type 'NoneType' has no len()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[51], line 3
      1 # pas mal non plus
      2 
----> 3 len(reachables(thrones, 'Bran')) == 42

TypeError: object of type 'NoneType' has no len()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[52], line 3
      1 # plus secondaire déjà
      2 
----> 3 len(reachables(thrones, 'Davos')) == 3

TypeError: object of type 'NoneType' has no len()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[53], line 3
      1 # pas trop populaire non plus
      2 
----> 3 len(reachables(thrones, 'Shireen')) == 4

TypeError: object of type 'NoneType' has no len()

plus court chemin

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[54], line 2
      1 # des plus courts chemins
----> 2 d, path = shortest_path(thrones, 'Eddard', 'Doran')
      3 d == 15 and len(path) == 4 and 'Catelyn' in path

TypeError: cannot unpack non-iterable NoneType object
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[55], line 1
----> 1 d, path = shortest_path(thrones, 'Eddard', 'Margaery')
      2 d == 17 and set(path) == {'Eddard', 'Sansa', 'Renly', 'Margaery'}

TypeError: cannot unpack non-iterable NoneType object
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[56], line 1
----> 1 (d, path) = shortest_path(thrones, 'Daenerys', 'Karl')
      2 d == 38 and path == ['Daenerys', 'Viserys', 'Tyrion', 'Janos', 'Mance', 'Craster', 'Karl']

TypeError: cannot unpack non-iterable NoneType object
True

optimisation (optionnel / avancé)

quelque chose de louche

l’algorithme de plus court chemin que nous avons écrit jusqu’ici a surtout des avantages pédagogiques
l’intérêt est d’écrire un code qui s’écrit et se lit facilement

par contre, le lecteur affuté aura remarqué la chose suivante :

ce qui peut nous laisser penser que, dans le cas de graphes plus substanciels que nos exemples jusqu’ici, l’algorithme risque d’avoir des performances sous-optimales (c’est une litote)

un graphe plus gros

pour n=4:

exercice: pour un entier nn, écrire une fonction planar(n)
qui construit un graphe:


{(1, 1): {(2, 1): 1, (1, 2): 1}, (1, 2): {(2, 2): 1, (1, 3): 2}, (1, 3): {(2, 3): 1, (1, 4): 3}, (1, 4): {(2, 4): 1}, (2, 1): {(3, 1): 2, (2, 2): 1}, (2, 2): {(3, 2): 2, (2, 3): 2}, (2, 3): {(3, 3): 2, (2, 4): 3}, (2, 4): {(3, 4): 2}, (3, 1): {(4, 1): 3, (3, 2): 1}, (3, 2): {(4, 2): 3, (3, 3): 2}, (3, 3): {(4, 3): 3, (3, 4): 3}, (3, 4): {(4, 4): 3}, (4, 1): {(4, 2): 1}, (4, 2): {(4, 3): 2}, (4, 3): {(4, 4): 3}, (4, 4): {}}

%timeit

on va utiliser la magic timeit:

en l’occurrence, timeit nous permet de mesurer le temps que prend une instruction
celle-ci est exécutée plusieurs fois, on prend ensuite la moyenne

pour faire la même chose en Python pur, voyez .. le module timeit

mesurons: n=10 et plus

63.1 ns ± 6.4 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
60 ns ± 0.0327 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

la notion de profiling

ce qui nous donne l’occasion de parler un peu de profiling
de quoi s’agit-il ? principalement:

la doc de référence est ici
https://docs.python.org/3/library/profile.html
cherchez la phrase

The files cProfile and profile can also be invoked as a script to profile another script. For example:

profilons

il existe aussi des magic pour cela, mais par expérience elles sont d’un abord plus aride (un comble!)

aussi on va avoir recours au terminal et à l’interpréteur;
on écrit un script slow.py qui contient ceci

slow.py
from graphs import shortest_path1, planar1

N = 30
P = planar1(N)
print(shortest_path1(P, (1, 1), (N, N)))

et maintenant on peut lancer le profiler avec cette phrase

python -m cProfile slow.py

je vous invite à lire la documentation du profiler (lien ci-dessus) pour comprendre la signification des différentes colonnes

si on veut trier le résultat selon un critère particulier on fera par exemple

python -m cProfile -s tottime slow.py

exo #6 : challenge

une fois qu’on a vu ça, voyez-vous une façon de récrire shortest_path pour ne plus tomber dans cet inconvénient ?

voici les résultats que j’obtiens à présent avec une implémentation alternative et plus efficace:

405 μs ± 432 ns per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
3.13 ms ± 9.35 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
198 ms ± 1.02 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
1.47 s ± 213 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)