Etape 4 – Arcs de graphe

1 Introduction

Le but de cette étape est d'écrire la classe représentant les arcs du graphe de l'horaire. Ce graphe est la structure de données centrale du projet, puisque c'est grâce à lui qu'il est possible de déterminer la manière la plus rapide de se déplacer d'un point à un autre dans le réseau des tl.

Les graphes sont à la base de tous les systèmes de routage, qu'ils soient prévus pour les transports individuels (à horaires flexibles), ou les transports publics (à horaires fixes). Parmi les systèmes de routage en ligne, on peut citer OSRM (version Suisse) basé sur OpenStreetMap, Google Maps, ViaMichelin, CFF, search.ch, et bien d'autres encore.

1.1 Graphe

En mathématiques, plus précisément en théorie des graphes, un graphe est défini comme une structure composée d'un ensemble de nœuds, appelés aussi sommets, reliés entre-eux par des liens.

Il existe deux grandes classes de graphes : les graphes non orientés, dans lesquels les liens n'ont pas de sens et sont appelés arêtes, et les graphes orientés, dans lesquels les liens ont un sens et sont appelés arcs. Les graphes utilisés dans les applications de routage sont toujours orientés, et nous ne considérerons donc que ce type de graphe ici.

Les graphes sont souvent représentés visuellement sous la forme d'un ensemble de boîtes représentant les nœuds et de lignes liant ces boîtes entre-elles et représentant les liens. Dans le cas des graphes orientés, la direction des arcs est représentée par des pointes de flèches. Par exemple, l'image ci-dessous montre un graphe orienté comportant 6 nœuds étiquetés de A à F, reliés par 7 arcs : un premier arc va de C à A, un second de A à B, un troisième de A à D, et ainsi de suite. A noter que le nœud E n'est connecté à aucun autre mais fait néanmoins partie du graphe.

graph-example.svg

Figure 1 : Un graphe à 6 nœuds et 7 arcs

Aussi bien les nœuds que les arcs peuvent être annotés avec une information spécifique à l'application. Par exemple, dans un graphe représentant un réseau routier et dont les nœuds sont des villes et les arcs les routes qui les relient, on peut imaginer annoter les nœuds avec les noms et positions géographiques des villes, et les arcs avec la longueur des routes les reliant. Le graphe ci-dessous illustre cette idée avec quatre villes de Suisse romande.

graphe-villes-romandes.svg

Figure 2 : Graphe des distances entre quatre villes romandes

On constate sur ce graphe que certaines paires d'arcs symétriques et annotés identiquement sont représentées par une unique flèche bidirectionnelle, pour alléger la présentation. Par exemple, la flèche bidirectionnelle annotée 64 km et reliant les nœuds Lausanne et Genève représente en fait deux arcs : le premier, annoté 64 km et allant du nœud Lausanne au nœud Genève, et le second, également annoté 64 km, allant du nœud Genève au nœud Lausanne. Toutes les paires d'arcs symétriques sauf celle liant les nœuds Neuchâtel et Fribourg ont été représentées ainsi.

1.2 Graphe d'horaire

Il existe plusieurs manières de représenter les horaires de transports en commun par un graphe. Pour ce projet, nous utiliserons une représentation nommée graphe dépendant du temps. Dans cette représentation, les nœuds du graphe représentent les arrêts de transports en commun, et les arcs sont annotés avec deux informations relatives au trajet du nœud de départ à celui d'arrivée :

  1. la durée de ce trajet à pied,
  2. les heures de départ et la durée des différents trajets en transports publics.

La figure ci-dessous donne un exemple fictif d'un tel graphe pour une minuscule région lausannoise. Les sommets sont quatre arrêts (commerciaux) des tl, et les arcs sont annotés avec les deux informations susmentionnées.

time-expanded-graph-example.svg

Figure 3 : Exemple fictif de graphe dépendant du temps

En examinant ce graphe, on constate que si l'on se trouve à 9h18 à l'arrêt Bel-Air et que l'on désire se rendre le plus rapidement possible à l'arrêt B.-Constant, la meilleure solution consiste à attendre jusqu'à 9h20 l'arrivée d'un véhicule pour St-François, où on arrive après 3 minutes de trajet (donc à 9h23) puis soit de continuer avec le véhicule qui part à 9h25 et arrive deux minutes plus tard (donc à 9h27) à B.-Constant, soit de continuer à pied pour arriver, en 4 minutes, à 9h27 également. Le trajet dure au total 9 minutes, trois de moins que si l'on fait tout à pied.

On constate par contre que si l'on se trouve à 10h00 à l'arrêt Bel-Air, alors la solution la plus rapide pour se rendre à B.-Constant est de marcher et d'y arriver 12 minutes plus tard. Le prochain véhicule ne circule effectivement pas avant 9h20 le lendemain, à supposer que le graphe du lendemain soit identique.

A noter qu'en consultant un tel graphe, il est impossible de savoir si un changement de véhicule est nécessaire ou pas. Comme expliqué dans le document d'introduction du projet, ce choix a été fait pour simplifier les choses, les erreurs qu'il introduit n'étant généralement pas importantes pour cette application.

2 Mise en œuvre Java

Le graphe des horaires est mis en œuvre en Java par deux classes du paquetage ch.epfl.isochrone.timetable : la classe Graph représente le graphe lui-même, tandis que la classe GraphEdge représente les arcs annotés. La classe Graph sera l'objet de la prochaine étape, GraphEdge celui de cette étape-ci.

Idéalement, la classe GraphEdge devrait être une classe privée imbriquée statiquement dans la classe Graph et nommée alors simplement Edge. Toutefois, elle sera ici au niveau supérieur mais visible uniquement dans son paquetage, principalement pour des raisons de découpage du projet en étapes et de simplification du test.

2.1 Trajets

Comme la figure 3 l'illustre, les arcs sont annotés avec une liste de trajets, c-à-d une liste de couples (heure de départ, durée du trajet). Avant de pouvoir écrire la classe des arcs, il faut donc décider d'une représentation de ces trajets.

La représentation conforme aux principes de la programmation objet serait, bien entendu, une classe comportant l'heure de départ et la durée du trajet comme champs. Toutefois, comme nous l'avons fait avec les heures elles-mêmes à l'étape 2, nous allons ici enfreindre ces principes dans un soucis de simplicité et d'économie de mémoire et de temps. En fait, la représentation des heures sous forme d'entiers était justifiée uniquement par le souci de pouvoir représenter les trajets de manière efficace.

Lors de l'écriture de la classe SecondsPastMidnight, nous avons décrété que la plus grande valeur d'heure valide était celle représentant 29 h 59 min 59 s, soit 107'999 secondes. Or la plus grande valeur positive que l'on peut stocker dans un entier de type int en Java est 231-1, soit 2'147'483'647. Même la plus grande valeur d'heure valide est loin d'utiliser la totalité de la capacité d'un entier int, et on peut donc se demander s'il n'est pas possible d'y placer les deux composantes d'un trajet.

On constate qu'en multipliant l'heure du trajet par 10'000, on obtient une valeur comprise entre 0 et 1'079'990'000, qui tient encore dans un entier de type int. Cette valeur étant un multiple de 10'000 par construction, ses quatre chiffres les plus à droite, en représentation décimale, sont forcément 0. On peut donc y stocker la durée du trajet, pour peu que celui-ci soit toujours compris entre 0 et 9'999.

Si la durée du trajet est représentée en secondes, 9'999 représente 2 h 46 min 39 s. Cela semble bien au-delà de la durée maximale raisonnable d'un trajet d'un arrêt à un autre dans la région lausannoise.

Pour résumer, les deux composantes d'un trajet, à savoir son heure de départ et sa durée, peuvent être stockées dans un seul entier Java de type int. Il suffit pour cela de placer l'heure de départ, représentée en secondes après minuit, dans les 6 chiffres les plus à gauche (dits de poids fort) de la représentation décimale de cet entier, tandis que la durée, représentée en secondes, est placée dans les 4 chiffres les plus à droite (dits de poids faible).

Prenons par exemple un trajet dont l'heure de départ est 9h20 et la durée 3 minutes. Exprimée en secondes après minuit, l'heure de départ est 33'600. Exprimée en secondes, la durée vaut quant à elle 180. En multipliant la première valeur par 10'000 et en y ajoutant la seconde, on obtient la valeur 336'000'180, qui représente le trajet. Il est facile de voir qu'en divisant cette valeur (de manière entière) par 10'000, on obtient l'heure de départ du trajet exprimée en secondes, tandis qu'en calculant le reste de cette même division, on obtient la durée du trajet en secondes.

Ce genre d'encodages est assez fréquent en informatique. Par exemple, les trois composantes couleur R, G et B des pixels d'une image sont souvent placées toutes ensembles dans un seul entier. Toutefois, les facteurs de multiplication utilisés sont généralement des puissances de 2 et pas de 10 comme ci-dessus, car les opérations de multiplication, division et calcul de reste peuvent alors se faire au moyen d'opérations sur les bits, très rapides. Les personnes à l'aise avec ces encodages peuvent, si elle le désirent, remplacer le facteur multiplicatif de 10'000 utilisé plus haut par 214=16'384 et utiliser ensuite les opérateurs sur les bits de Java.

Après avoir décidé de la manière d'encoder les trajets, il reste à savoir où placer les méthodes d'encodage et de décodage. On pourrait les définir comme méthodes statiques d'une classe dont elles seraient la seule raison d'être, à l'image de SecondsPastMidnight, mais étant donné que les trajets ne sont utiles que dans le contexte des arcs de notre graphe, nous placerons ces méthodes directement dans la classe des arcs, définie ci-après.

2.2 Classe GraphEdge

La classe GraphEdge du paquetage ch.epfl.isochrone.timetable, immuable, finale et visible uniquement dans son paquetage, modélise un arc annoté du graphe des horaires. Elle contient aussi, comme dit précédemment, les méthodes d'encodage et de décodage des trajets.

Pour des raisons qui deviendront claires à la prochaine étape, seul l'arrêt de destination d'un arc est stocké dans l'arc lui-même.

Interface publique

La classe GraphEdge est équipée des méthodes publiques et statiques suivantes, qui permettent d'encoder et de décoder des trajets selon la technique décrite plus haut :

  • int packTrip(int departureTime, int arrivalTime), qui encode en entier un trajet donné par son heure de départ et son heure d'arrivée. Lève l'exception IllegalArgumentException si l'heure de départ est invalide, c-à-d hors de l'intervalle [0;107'999], ou si la différence entre l'heure d'arrivée et celle de départ est invalide, c-à-d hors de l'intervalle [0;9'999].
  • int unpackTripDepartureTime(int packedTrip), qui extrait l'heure de départ d'un trajet encodé.
  • int unpackTripDuration(int packedTrip), qui extrait la durée, exprimée en secondes, d'un trajet encodé.
  • int unpackTripArrivalTime(int packedTrip), qui extrait l'heure d'arrivée du trajet encodé passé en argument.

Elle est équipée du constructeur public suivant :

  • GraphEdge(Stop destination, int walkingTime, Set<Integer> packedTrips), qui construit un arc ayant l'arrêt de destination, le temps de marche et les trajets donnés. Le temps de marche est exprimé en secondes, et peut valoir -1, ce qui signifie qu'il est trop long d'effectuer le trajet à pied. Lève l'exception IllegalArgumentException si le temps de marche est inférieur à -1.

Elle possède de plus les méthodes suivantes :

  • Stop destination(), qui retourne l'arrêt destination de l'arc.
  • int earliestArrivalTime(int departureTime), qui retourne la première heure d'arrivée possible à la destination de l'arc, étant donnée l'heure de départ. Cette heure doit être SecondsPastMidnight.INFINITE s'il n'est pas possible d'effectuer le trajet à l'heure de départ donnée—c-à-d si le temps de marche est -1 et l'heure de départ du dernier trajet est antérieure à l'heure de départ donnée.

Conseils de programmation

La classe GraphEdge doit être immuable, ce qui implique comme d'habitude qu'il faut copier les éventuelles valeurs modifiables passées à son constructeur avant de les stocker.

Pour calculer la première heure d'arrivée à destination, il suffit de prendre le minimum de l'heure d'arrivée à pied et de l'heure d'arrivée en transports publics.

Le calcul de l'heure d'arrivée à pied ne pose pas de problème, mais le calcul de l'heure d'arrivée en transports publics est plus intéressant. La solution la plus simple pour la calculer consiste à parcourir la totalité des trajets de l'arc, de calculer l'heure d'arrivée de tous ceux qui ne partent pas trop tôt, et de garder la valeur minimale. Cette recherche a une complexité de \(O(n)\) où \(n\) est le nombre de trajets.

En faisant preuve de passablement d'astuce, il est toutefois possible d'effectuer cette recherche en \(O(\log n)\). Cette solution n'est décrite que succinctement ci-dessous, mais il est fortement recommandé d'essayer de la mettre en œuvre car l'exercice est instructif.

L'idée de la recherche rapide consiste à stocker les trajets dans un tableau trié. Le tri lui-même se fait facilement au moyen de la méthode sort de la classe Arrays. Le tableau étant trié, il devient possible de rechercher le prochain départ via la technique de la dichotomie vue au cours et mise en œuvre par la méthode binarySearch de la classe Arrays.

Toutefois, deux difficultés se présentent : d'une part, il est rare qu'un véhicule parte justement à l'heure de départ désirée, il faut généralement attendre le prochain départ. Il semble donc que pour pouvoir lancer la recherche dichotomique, il faille connaître à l'avance l'heure du prochain départ, or c'est justement ce que l'on cherche… D'autre part, les entiers stockés dans le tableau susmentionné contiennent à la fois l'heure de départ et la durée du trajet, encodés comme expliqué précédemment. Il semble donc qu'il soit nécessaire de connaître aussi la durée du trajet afin de lancer la recherche…

Ces deux problèmes, qui n'en sont en fait qu'un, trouvent leur solution dans le fait que la méthode binarySearch retourne soit la position de l'élément recherché s'il est présent dans le tableau, soit la position d'insertion de cet élément s'il est absent (en réalité, elle retourne un de moins que la position d'insertion multipliée par -1, voir la documentation pour les détails). Il est en effet possible d'utiliser cette position d'insertion pour trouver immédiatement le prochain trajet. A vous de découvrir comment !

2.3 Classe GraphEdge.Builder

La classe Builder, publique, finale et imbriquée statiquement dans la classe GraphEdge sert de bâtisseur à la classe Edge.

Interface publique

La classe GraphEdge.Builder comporte un unique constructeur public :

  • Builder(Stop destination), qui construit un bâtisseur pour un arc ayant l'arrêt donné comme destination, aucun trajet et un temps de marche de -1, signifiant qu'il est trop long d'effectuer le trajet à pied.

Elle possède de plus les méthodes publiques suivantes :

  • GraphEdge.Builder setWalkingTime(int newWalkingTime), qui change le temps de marche pour qu'il soit égal à la valeur passée en argument. Lève l'exception IllegalArgumentException si cet argument est inférieur à -1. Retourne this afin de permettre les appels chaînés.
  • GraphEdge.Builder addTrip(int departureTime, int arrivalTime), qui ajoute un trajet avec les heures de départ et d'arrivée données et exprimées en nombre de secondes après minuit. Lève l'exception IllegalArgumentException dans les mêmes situations que packTrip appliquée aux mêmes argument.
  • GraphEdge build(), qui retourne un nouvel arc avec la destination, le temps de marche et les trajets ajoutés jusqu'ici au bâtisseur.

Conseils de programmation

Pour simplifier les choses, vous avez le droit de ne pas vérifier les arguments de addTrip et de simplement laisser packTrip lever une exception au besoin.

2.4 Tests

Comme pour l'étape précédente, nous vous fournissons des « tests » minimalistes qui ne font rien d'autre que s'assurer que vous n'avez pas commis de faute bête dans le nommage ou la visibilité des entités à définir. Ils se trouvent dans l'archive Zip tests-e04.zip.

N'oubliez donc pas de les compléter, et de bien tester vos méthodes d'encodage et de décodage de trajets, ainsi que la méthode earliestArrivalTime, probablement la plus complexes de cette étape.

3 Résumé

Pour cette étape, vous devez :

  1. Ecrire la classes GraphEdge en fonction des spécifications ci-dessus.
  2. Compléter les classes de test que nous vous fournissons.
  3. Documenter la totalité des entités publiques que vous avez définies, y compris la classe GraphEdge, malgré le fait qu'elle ne soit visible que dans son paquetage.