Backtracking

Dans cette série d’exercices, vous allez programmer des algorithmes destinés à résoudre des problèmes de satisfaction de contraintes :

  • La consistance de noeuds et d’arcs.
  • L’algorithme de recherche par backtracking.

Dans la série suivante, vous appliquerez ces algorithmes au jeu du Sudoku.

Modules squelettes

Les modules moteur_psc suivants contiennent le squelette du programme que nous allons développer. Le module exemple_backtracking.py permettra de le tester.

backtracking.zip

Module .../moteur_psc/variable.py :

class Variable:
    """ Modélisation d'une variable dans un système de contraintes."""

    def __init__(self, nom, domaine, val=None):
        """
            :param str nom: nom de la variable.
            :param list domaine: le domaine de définition de la variable.
            :param val: valeur de départ.
        """

        self.nom = nom
        self.domaine = domaine
        self.val = val

    def taille_domaine(self):
        """ La taille du domaine de définition de la variable.

            :return: la taille du domaine.
        """

        return len(self.domaine)

    def __eq__(self, that):
        return self.nom == that.nom

    def __hash__(self):
        return sum(map(ord, self.nom))

    def __repr__(self):
        return '{} = {}, domaine: {}'.format(self.nom, self.val, self.domaine)

Module .../moteur_psc/contrainte.py :

class Contrainte:
    """ Modélisation d'une contrainte abstraite."""

    def __init__(self, variables):
        """
            :param list variables: les variables concernées par la contrainte.
        """

        self.variables = tuple(variables)

    def dimension(self):
        """
            :return: le nombre de variables concernées par la contrainte.
        """

        return len(self.variables)

    def est_valide(self):
        """ Teste si la contrainte est respectée par les valeurs actuelles.

            :return: ``True`` si la contrainte est valide.
        """

        return False

    def __repr__(self):
        return 'Contrainte: {}'.format(self.variables)

    def __eq__(self, that):
        return self.variables == that.variables

    def __hash__(self):
        return sum([v.__hash__ for v in self.variables])

class ContrainteUnaire(Contrainte):
    """ Contrainte imposant une restriction sur la valeur d'une variable.

        Exemples: ``x > 0``, ``y = 5``.
    """

    def __init__(self, var, op):
        """ Exemples d'op: ``lambda x: x > 5``, ``lambda x: x > 5 and x < 10``.

            :param var: variable concernée par la contrainte.
            :param op: fonction ou expression lambda permettant de vérifier la\
            contrainte.
        """

        Contrainte.__init__(self, (var,))
        self.op = op

    def est_valide(self, val):
        """ Teste si la contrainte est valide quand la variable ``var``\
            prend la valeur ``val``.

            La contrainte unaire est respectée si l'opérateur appliqué à\
            l'opérande ``val``  retourne ``True``.

            :return: ``True`` si la contrainte est valide.
        """

        print('à compléter')

class ContrainteBinaire(Contrainte):
    """ Contrainte imposant une restriction sur deux variables.

        Exemple: ``x > y``.
    """

    def __init__(self, var1, var2, op):
        """ Exemples d'op: ``lambda x,y: x != y``, ``lambda x,y: x < y``.

            :param var1: première variable concernée par la contrainte.
            :param var2: deuxième variable concernée par la contrainte.
            :param op: fonction ou expression lambda permettant de vérifier la\
            contrainte.
        """

        Contrainte.__init__(self, (var1, var2))
        self.op = op

    def est_valide(self, var, val):
        """ Teste si la contrainte est valide quand la variable ``var``\
            (soit ``var1``, soit ``var2``) prend la valeur ``val``.

            La contrainte unaire est respectée si l'opérateur appliqué aux\
            opérandes ``val, var2.val`` (lorsque ``var`` est ``var1``) ou\
            ``var1.val, val`` (lorsque ``var`` est ``var2``) retourne ``True``.

            :return: ``True`` si la contrainte est valide.
        """

        print('à compléter')

    def est_possible(self, var):
        """ Teste si le domaine de ``var`` contient au moins une valeur\
            satisfaisant la contrainte.

            :return: ``True`` s'il existe au moins une valeur satisfaisant\
            la contrainte.
        """

        print('à compléter')

    def reviser(self):
        """ Algorithme de révision des domaines.

            Pour chaque variable, vérifie chaque valeur du domaine. Supprime\
            les valeurs qui empêchent la contrainte d'être satisfaite dans le\
            domaine.

            :return: ``True`` si un des domaines a été modifié.
        """

        print('à compléter')

Module .../moteur_psc/moteur_psc.py :

class PSC:
    def __init__(self, variables, contraintes):
        """
            :param list variables: les variables du problème.
            :param list contraintes: les contraintes du problème.
        """

        self.variables = variables
        self.contraintes = contraintes

        self.iterations = 0
        self.solutions = []

    def consistance_noeuds(self):
        """ Applique la consistance des noeuds sur les contraintes unaires du\
            problème.

            L'algorithme consiste à enlever des domaines de définition toutes\
            les valeurs qui violent les contraintes unaires.
        """

        print('à compléter')

    def consistance_arcs(self):
        """ Applique la consistance d'arcs sur les contraintes binaires du\
            problème.
        """

        print('à compléter')

    def consistance_avec_vars_precedentes(self, k):
        """ Vérifie si toutes les contraintes concernant les variables déjà\
            instanciées sont satisfaites.
        """

        print('à compléter')

    def backtracking(self, k=0, une_seule_solution=False):
        """ Algorithme de Backtracking simple.

            Tente d'assigner une valeur à chaque variable. Lors de chaque\
            assignation, vérifie que toutes les contraintes des variables\
            instanciées sont satisfaites. Sinon, revient en arrière pour essayer\
            une autre valeur.

            :param k: la profondeur de la recherche.
            :param une_seule_solution: indique à l'algorithme s'il faut\
            s'arrêter après avoir trouvé la première solution.
        """

        print('à compléter')

    def affiche_solutions(self):
        """ Méthode pour afficher les solutions découvertes par l'algorithme."""

        print('Recherche terminée en {} itérations'.format(self.iterations))

        if len(self.solutions) == 0:
            print('Aucune solution trouvée')
            return

        for sol in self.solutions:
            print('Solution')
            print('========')
            for (nom, var) in sorted(sol.items()):
                print('\tVariable {}: {}'.format(nom, var))

Module .../exemple_backtracking.py :

from moteur_psc.variable import Variable
from moteur_psc.contrainte import ContrainteUnaire, ContrainteBinaire
from moteur_psc.psc import PSC

variables = [
             Variable('a', [2, 3]),
             Variable('b', list(range(12))),
             Variable('c', list(range(3))),
             Variable('d', list(range(3))),
             Variable('e', list(range(12))),
             ]

contraintes = [
    ContrainteUnaire(variables[1], lambda x: x < 4),
    ContrainteBinaire(variables[0], variables[1], lambda x, y: x != y),
    ContrainteBinaire(variables[1], variables[2], lambda x, y: x != y),
    ContrainteBinaire(variables[1], variables[3], lambda x, y: x != y),
    ContrainteBinaire(variables[1], variables[4], lambda x, y: x != y),
    ContrainteBinaire(variables[2], variables[3], lambda x, y: x != y),
    ContrainteBinaire(variables[2], variables[4], lambda x, y: x != y),
    ContrainteBinaire(variables[3], variables[4], lambda x, y: x != y),
    ContrainteBinaire(variables[4], variables[0], lambda x, y: x < y),
]

psc = PSC(variables, contraintes)

psc.consistance_noeuds()
psc.consistance_arcs()
psc.backtracking(0, False)

psc.affiche_solutions()

Exercice 1 : Consistance des noeuds et des arcs

Les modules variable.py et contrainte.py contiennent les classes Variable et Contrainte, ainsi que les sous-classes de cette dernière, ContrainteUnaire et ContrainteBinaire. Le module psc.py implémente la classe PSC - une librairie pour la gestion d’un ensemble de variables et de contraintes, ainsi que pour la résolution d’un système de contraintes.

Comme vous pouvez le constater, les fonctions consistance_noeuds et consistance_arcs la classe PSC ne sont pas implémentées. Vous pouvez commencer par compléter ces fonctions, en suivant les indications suivantes :

  • La fonction consistance_noeuds doit appeler la méthode est_valide de la classe ContrainteUnaire, que vous devez aussi compléter ;
  • La fonction consistance_arcs doit appeler la méthode reviser de la classe ContrainteBinaire, dans laquelle vous devez implémenter l’algorithme de Waltz (consistance d’arcs). Comme les contraintes binaires sont bidirectionnelles, vous devrez implémenter la fonction reviser de telle sorte que les domaines des deux variables de la contrainte soient réduits (si possible) par l’appel à reviser ;
  • À son tour, la méthode reviser s’appuie sur les méthodes est_valide et est_possible de la classe ContrainteBinaire, que vous devez aussi compléter.

Exercice 2 : Algorithme du backtracking

Le Backtracking est un algorithme de recherche en profondeur d’abord avec les charactéristiques suivantes :

  • Une noeud de recherche est une instanciation de variables x_{1} = v_{1}, x_{2} = v_{2}, ..., x_{k} = v_{k} (où k est la profondeur du noeud dans l’arbre de recherche) ;
  • La fonction de successeur ajoute une nouvelle instanciation x_{k+1} = v_{k+1} de manière à respecter toutes les contraintes pour les variables x_{1}, ..., x_{k} ;
  • Le noeud initial est une instanciation vide ;
  • Un noeud but consiste en une instanciation de toutes les variables x_{1}, ..., x_{n}.

L’algorithme de recherche, dont nous vous donnons le pseudo-code ci-dessous, doit ainsi être implémenté dans la méthode backtracking de la classe PSC :

solutions <- []
variables <- [v1, v2, ..., vn]

Backtracking(k, une_seule_solution)
1.  IF k >= n THEN
2.      IF NOT une_seule_solution THEN
3.          ajouter la solution actuelle à solutions
4.      ELSE
5.          RETURN solutions = [solution actuelle]
6.      END IF
7.  ELSE
8.      v <- variables[k]
9.      FOR EACH valeur de domaine d de la variable v DO
10.         assigner la valeur d à la variable v
11.         vérifier la consistance de v=d avec les variables précédentes
12.         IF v=d est consistant THEN
13.             reste <- Backtracking(k+1, une_seule_solution)
14.             IF reste != échec THEN
15.                 RETURN reste
16.             END IF
17.         END IF
18.     END FOR
19. END IF
20. RETURN échec
END Backtracking

backtracking prend deux paramètres :

  • k : la profondeur courante (commence à 0) ;
  • une_seule_solution : si vrai, alors retourne la première solution trouvée, sinon retourne toutes les solutions possibles.

Les étapes 11 et 12 de l’algorithme ci-dessus seront implémentées à l’aide de la fonction consistance_avec_vars_precedentes de la classe PSC, que vous devez aussi compléter.

Les solutions seront stockées dans la variable de classe self.solutions, chacune étant représentée par un dictionnaire qui associera le nom de la variable à sa valeur. Comme ces solutions sont conservées dans un champ de la classe, il n’est donc pas indispensable de les retourner. En outre, au lieu de retourner une valeur spécial en cas d’échec, la méthode backtracking peut se terminer simplement sans valeur de retour.

Test du programme

Une fois que vous avez terminé, vous pouvez tester votre implémentation sur le module exemple_backtracking.py :

python3 exemple_backtracking.py

Exercice 3 : Permutations [OPTIONNEL]

S’il vous reste du temps, vous pouvez utiliser l’algorithme du backtrack pour afficher toutes les permutations possibles des entiers de 0 à N. Par permutation, nous entendons une solution dont les clés seront des indices et les valeurs, des entiers de 0 à N. Chacune de ces solutions doit contenir chaque valeur une fois et une fois seulement. Vous devrez donc définir un système de contraintes pour vous assurer que cette condition est respectée. Testez votre implémentation pour plusieurs valeurs de N. (Ne choisissez pas des valeurs trop élevées pour N, faute de quoi l’algorithme risque de tourner pendant des heures.)

Essayez ensuite d’imaginer d’autres contraintes pour restreindre l’ensemble des solutions et implémentez-les. Par exemple, on peut exiger que deux valeurs consécutives ne soient jamais toutes deux paires ou toutes deux impaires.