Résolution de problèmes par recherche
=====================================

Dans cette série d'exercices, vous allez vous familiariser avec les trois principaux algorithmes de recherche : 

* En profondeur d'abord (DFS) ; 
* En largeur d'abord (BFS) ;
* Par :math:`A^{\star}`. 

À titre d'exemple, vous les utiliserez pour découvrir un chemin entre deux villes étant donné leurs positions géographiques et les routes qui les connectent.

Modules squelettes
------------------

Les modules qui suivent constituent le squelette des programmes que vous allez implémenter. Les deux derniers modules, ``exemple_carte_simple.py`` et ``exemple_carte_suisse.py``, permettront de les tester.

:download:`recherche.zip <../../squelettes/recherche.zip>`

Module ``.../moteurs_recherche/element.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/element.py
    :code:

Module ``.../moteurs_recherche/ville.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/ville.py
    :code:

Module ``.../moteurs_recherche/espace.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/espace.py
    :code:

Module ``.../moteurs_recherche/noeud.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/noeud.py
    :code:

Module ``.../moteurs_recherche/recherche.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/recherche.py
    :code:

Module ``.../moteurs_recherche/bfs.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/bfs.py
    :code:

Module ``.../moteurs_recherche/dfs.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/dfs.py
    :code:

Module ``.../moteurs_recherche/astar.py`` :

.. include::  ../../squelettes/recherche/moteurs_recherche/astar.py
    :code:

Module ``.../exemple_carte_simple.py`` :

.. include::  ../../squelettes/recherche/exemple_carte_simple.py
    :code:

Module ``.../exemple_carte_suisse.py`` :

.. include::  ../../squelettes/recherche/exemple_carte_suisse.py
    :code:

Exercice 1 : Classes de base
----------------------------

La classe ``Element``
~~~~~~~~~~~~~~~~~~~~~

Dans cet exercice, vous devrez manipuler des éléments situés dans un espace de recherche --- plus précisément, des villes dans un espace à deux dimensions. L'espace de recherche est muni d'une notion de distance appliquable à chaque paire d'éléments. L'objectif des algorithmes de recherche que nous allons programmer sera de trouver le chemin le plus court entre deux éléments.

La classe ``Element`` du module ``element.py`` représentera donc un élément générique placé dans un espace de recherche. Cet élément sera caractérisé par un seul attribut, ``self.nom``. Commencez par compléter le constructeur de la classe, qui doit initialiser cet attribut au moyen d'une valeur passée en paramètre. Ensuite, complétez la méthode ``__eq__``, qui doit vérifier l'égalité entre l'élément courant et un autre. Deux éléments seront réputés égaux (``__eq__`` retourne ``True``) lorsqu'ils possèdent des noms égaux. Notez que ``Element`` contient aussi une fonction qui retourne la distance entre l'élément courant et un autre élément. Cette fonction n'a ici qu'une implémentation triviale et devra être surchargée de manière appropriée dans les sous-classes.

La classe ``Ville``
~~~~~~~~~~~~~~~~~~~

La classe ``Element`` nous fournit un modèle que les éléments de recherche doivent spécialiser. Notre but dans cet exercice est de trouver des chemins entre des villes, qui sont des éléments dans un espace à deux dimensions. Nous définissons donc une sous-classe ``Ville``, qui étend ``Element`` en lui ajoutant deux attributs :

* ``x``: la position de l'élément sur l'axe des :math:`x` ;
* ``y``: la position de l'élément sur l'axe des :math:`y`.

Commencez par coder le constructeur de la classe, qui doit initialiser ces attributs à partir des valeurs passées en paramètre. Ensuite, surchargez la méthode d'égalité ``__eq__``, afin qu'elle compare les noms et les coordonnées. Finalement, surchargez la fonction de distance, afin de retourner la distance euclidienne entre deux villes. Pour cette dernière opération, vous pouvez utiliser l'opérateur de mise à la puissance de Python, qui est ``**`` (par exemple : ``3**2 == 9``). En outre, ``from math import sqrt`` permet d'importer uniquement la fonction racine carrée du module ``math``.

La classe ``Espace``
~~~~~~~~~~~~~~~~~~~~

Dans l'espace de recherche, chaque élément sera lié à d'autre éléments placés à proximité --- ses voisins. Par exemple, si l'espace de recherche représente une carte de la Suisse, Lausanne sera parmi les éléments voisins de Genève. Nous indiquerons la proximité entre deux éléments par un tuple ``(element_1, element_2)``. Du point de vue formel, un tuple représente ainsi un arc dans le graphe constitué par les éléments et leurs relations de vicinité. 

Nous utiliserons la classe ``Espace`` pour représenter un espace de recherche. ``Espace`` stockera tous ses éléments dans une liste ``self.elements`` et tous ses arcs dans ``self.arcs``. Le constructeur initialise les listes d'éléments et d'arcs, soit avec des collections passées en paramètre, soit comme des listes vides lorsqu'il est appelé sans arguments.

La classe contient aussi les méthodes suivantes, que vous devez implémenter :

* ``ajoute_arcs(self, arcs)`` : prend en argument une collection de tuples ``(a, b)`` représentant des arcs, qu'elle ajoute aux collections de l'objet courant. (Cette méthode n'est pas absolument indispensable, car les listes peuvent aussi bien être remplies lors de la construction, mais il est parfois plus pratique et plus lisible d'ajouter des arcs par le biais d'une méthode.);
* ``trouve_voisins(self, element)`` : retourne la liste de tous les voisins d'un élément (par exemple, si l'arc ``(a, b)`` est le seul de l'espace ``e`` : ``e.voisins(a)`` doit retourner ``[b]``.

La classe ``Noeud``
~~~~~~~~~~~~~~~~~~~

Lors de la recherche, chaque ``Element`` sera modélisé par un noeud dans un arbre de recherche. Chaque noeud contiendra une référence sur un élément. Les noeuds seront crées de façon dynamique au cours de l'exploration de l'espace de recherche par l'algorithme. 

Nous avons donc besoin d'une classe ``Noeud`` (squelette disponible dans ``noeud.py``), permettant de modéliser un noeud. Cette classe contiendra quatre attributs :

*  ``element`` : une référence sur un objet de type ``Element`` ;
*  ``cout_c`` : contient le coût :math:`c(n)`, c'est-à-dire le coût depuis le noeud de départ jusqu'au noeud en question. Il s'agit de la somme minimale des longueurs des arcs entre l'élément référencé par le noeud de départ et l'élément référencé par le noeud courant. Dans notre cas, la longueur d'un arc est donnée par la distance entre ses deux éléments ;
*  ``cout_f`` : contient le coût :math:`f(n)`, c'est-à-dire le coût heuristique utilisé par l'algorithme :math:`A^{\star}`, qui est égal à :math:`c(n)+h(n)`. Dans notre cas, la fonction heuristique :math:`h(n)` calcule la distance euclidienne entre l'élément contenu par le noeud courant et l'élément but ; le coût heuristique modélise ainsi la distance au but, de manière à privilégier l'exploration du noeud le plus prometteur ;
*  ``parent`` : le noeud parent du noeud courant, c'est-à-dire le noeud qui a conduit au noeud courant durant la recherche.

Écrivez donc un constructeur qui initialise ces quatre attributs à partir de valeurs passées en paramètres. 

Exercice 2 : Algorithmes de recherche
-------------------------------------

Nous allons maintenant développer un outil de recherche qui implémente les trois principaux algorithmes : DFS (*Depth-first search* : Recherche en profondeur d'abord), BFS (*Breadth-first search*: Recherche en largeur d'abord) et :math:`A^{\star}` (Recherche par :math:`A^{\star}`).

Le principe de tout algorithme de recherche est de trouver un élément but (dans notre exemple, la ville de destination) à partir d'un élément initial (la ville de départ). Lors de l'exploration de l'espace des éléments, la recherche traverse un arbre constitué de noeuds qui sont liés à leurs noeuds successeurs. Chaque noeud de recherche correspond à une étape dans la recherche. L'exploration d'un noeud de recherche permet, s'il en a, de trouver ses successeurs --- qui deviennent de nouveaux noeuds de recherche.

Les algorithmes que nous allons étudier ne se différencient que par la gestion de la liste des noeuds ouverts ``Q`` : 

*  DFS  (en profondeur d'abord) : place les nouveaux noeuds en tête de ``Q`` ;
*  BFS  (en largeur d'abord) : place les nouveaux noeuds en queue de ``Q`` ;
*  :math:`A^{\star}` : insère les nouveaux noeuds de telle sorte que ``Q`` soit toujours ordonnée selon le coût heuristique croissant de ces noeuds.

Le pseudocode de l'algorithme de recherche vous est donné ci-dessous :

::

    Recherche(noeud_depart, noeud_but, methode)
     1. Q <- [noeud_depart] 
     2. WHILE Q n'est pas vide DO
     3.     n <- premier(Q)
     4.     Q <- reste(Q)
     5.     IF n == noeud_but THEN
     6.         RETURN chemin de noeud_depart à n
     7.     ELSE
     8.         S <- successeurs de n
     9.         Q <- AjouterSuccesseurs(Q, S, methode)
    10.     END IF
    11. END WHILE
    12. RETURN échec  
    END Recherche

Comme vous pouvez le constater, l'algorithme de base est le même quelle que soit la méthode utilisée (DFS, BFS et :math:`A^{\star}`). La seule différence réside dans la façon d'ajouter les successeurs à la liste ``Q``. Plus concrètement, la fonction qui ajoute les successeurs à ``Q`` est définie comme suit :

::

    AjouteSuccesseurs(Q, S, methode)
     1. IF methode == DFS THEN 
     2.     RETURN S + Q
     3. ELSE IF methode == BFS THEN
     4.     RETURN Q + S
     5. ELSE IF methode == A* THEN
     6.     Q <- Q + S
     7.     Q <- Q trié par coût heuristique 
     8.     RETURN Q
     9. ELSE
    10.     RETURN échec
    11. END IF
    END AjouteSuccesseurs

Les classes de recherche
~~~~~~~~~~~~~~~~~~~~~~~~

L'algorithme de recherche doit être implémenté dans la classe ``Recherche`` de ``recherche.py``. Un constructeur de la classe, qui initialise le graphe de recherche, vous est déjà donné. Également donnée est une fonction d'interface ``recherche``, qui prend en paramètres deux éléments, crée deux noeuds de recherche contenant ces éléments, et les passe en arguments à la méthode ``recherche_chemin``. Cest dans celle-ci que vous devez implémenter l'algorithme. Appelez les méthodes ``trouve_successeurs`` et ``ajoute_successeurs`` aux étapes 8 et 9 de l'algorithme. Afin de retourner le chemin depuis le noeud de départ, appelez la méthode ``trouve_chemin``, qui vous est déjà donnée. 

Ensuite, complétez la méthode ``trouve_successeurs``. Cette méthode doit retourner une liste contenant tous les successeurs d'un noeud passé en paramètre. La méthode doit retourner tous les éléments voisins de l'élément encapsulé par le noeud courant, eux-mêmes contenus dans de nouveaux noeuds de recherche (sauf le parent du noeud courant, pour éviter les cycles...). Notez qu'en créant ces nouveaux noeuds, vous devez aussi initialiser leurs coûts, à partir du coût du noeud courant et en utilisant la fonction heuristique ``self.h``. Cette dernière est initialisée à l'aide d'une fonction lambda dans la méthode d'interface ``recherche``. 

Ensuite, vous pouvez passer à la méthode ``ajoute_successeurs``. Il convient d'implémenter cette méthode différemment pour chacun des trois algorithmes, dans les sous-classes de ``Recherche`` : ``RechercheDFS`` (``dfs.py``), ``RechercheBFS`` (``bfs.py``) et ``RechercheAStar`` (``astar.py``) 

Test du programme
-----------------

Testez vos algorithmes sur les fichiers ``exemple_carte_simple.py`` et ``exemple_carte_suisse.py``.

::

    python3 exemple_carte_simple.py
    python3 exemple_carte_suisse.py

Qu'en concluez-vous ? Quelle est l'importance de l'heuristique utilisée par :math:`A^{\star}` ? Regardez surtout le nombre de noeuds de recherche examinés. Que pouvez-vous conclure sur les algorithmes en regardant la longueur des chemins (en nombre de villes traversées) ? 

Pourquoi l’algorithme DFS boucle-t-il à l’infini sur les deux cartes ? Testez maintenant en commentant le code appelant l’algorithme DFS. Quel résultat obtenez-vous ?

Exercice 3: Algorithmes de recherche avec détection de cycles
-------------------------------------------------------------

Comme vous avez pu le constater, l'algorithme n'est pas très efficace car, dans un espace de recherche cyclique, certains noeuds peuvent être visités à plusieurs reprises. Pire, l'algorithme peut se retrouver prisonnier d'une boucle infinie.

Détecter et éviter les cycles nécessite de maintenir une liste des noeuds déjà explorés. Dans le cas du DFS et du BFS, l'algorithme devra contrôler si ce noeud est déjà présent dans la liste avant de chercher ses successeurs. Lorsque c'est le cas, nous savons que les successeurs ont déjà été construits, et il n'est pas utile de recommencer. L'algorithme :math:`A^{\star}` est un peu plus compliqué de ce point de vue : on doit revisiter un noeud si et seulement si le coût ``f(n)`` est inférieur au coût ``f(n)`` du noeud la dernière fois qu'il a été exploré

En résumé, l'algorithme de recherche optimisé est le suivant :

::

    RechercheOptimisee(noeud_depart, noeud_but, methode)
     1. Q <- [noeud_depart] 
     2. C <- [] 
     3. WHILE Q non vide DO
     4.     n <- premier(Q), Q <- reste(Q)
     5.     IF n == noeud_but THEN
     6.         RETURN chemin de noeud_depart à n
     7.     ELSE
     8.         IF n not in C or 
                   (n=n' in C and f(n)<f(n') and methode is "A*")  THEN
     9.             S <- successeurs de n
    10.             Q <- AjouterSuccesseurs(Q, S, methode)
    11.             ajoute n dans C
    12.         END IF
    13.     END IF
    14. END WHILE
    15. RETURN échec  
    END RechercheOptimisee

En vous basant sur le pseudocode ci-dessus, implémentez l'algorithme de recherche optimisé. Vous devrez d'abord implémenter la méthode ``detecte_cycle``, qui teste si un noeud a déjà été exploré, étant donné le noeud courant et la collection ``trace`` des noeuds déjà explorés. Notez que vous devez surcharger cette méthode dans la classe ``RechercheAStar``, afin de tolérer les cycles qui permettent de trouver un chemin plus court.

Nous vous recommandons de ne pas implémenter une nouvelle méthode ``recherche_chemin``, mais de modifier la version existante afin de traiter le cas où l'attribut de la classe ``self.optimisee`` prend la valeur ``True``. En outre, afin d'optimiser la détection des cycles, nous vous suggérons d'implémenter ``trace`` comme un dictionnaire qui associe chaque élément au noeud de recherche qui le contient. 

Lorsque vous avez terminé, vous pouvez tester à nouveau votre programme. Qu'observez-vous cette fois ?

Exercice 4 : Labyrinthe
-----------------------

À titre d'exercice supplémentaire, vous pouvez tester votre implémentation en lui faisant découvrir un chemin entre deux points d'un labyrinthe. Pour simplifier, vous coderez ce dernier sous la forme d'une matrice de caractères ``L``, telle que ``L[i][j]`` prenne la valeur ``'x'`` si la cellule ``(i,j)`` est occupée, et ne peut pas être explorée. 

Vous pouvez ensuite implémenter une méthode qui traduira cette matrice en un objet ``Espace``, qui contiendra les déplacements possibles depuis chaque cellule (par exemple, en haut, en bas, à gauche et à droite si les cellules voisines sont libres). Appliquez les trois algorithmes de recherche pour construire le chemin conduisant d'un point quelconque à un autre et comparez les résultats.
