Programmation Fonctionnelle, TP9

Figure
Version & licenses
Creative Commons License

Programmation Fonctionnelle, TP9 : Le compte est bon

Guyslain Naves

Ce dernier TP en OCaml (le TP 10 sera en Java) nous permet de revoir la plupart des fonctionnalités que nous avons vu tout au long du cours : fonctionnelles, types algébriques et paramétrés, foncteurs, listes, etc. Le but est d'écrire un solveur pour le jeu "le compte est bon" . Dans ce jeu, plusieurs entiers sont donnés, qui doivent être combinés à l'aide des opérateurs arithmétiques, afin d'atteindre un objectif entier. Par exemple, si l'objectif est 643 et les entiers disponibles sont 3,3,5,8,10,25, une solution est $(10 \times 8 \times (3 + 5)) + 3$.

Dictionnaires d'entiers, de listes.

Définissez le module de type Set.OrderedType sur les entiers. En déduire le module d'un dictionnaire d'entier.

Définissez le module de type Set.OrderedType sur les listes d'entiers. En déduire un module de dictionnaire de listes d'entiers.

  1. module IntMap : Map.S with type key = int
  2. module IntListMap : Map.S with type key = int list

Combinatoires de listes.

Vous pouvez utiliser Core.Std.List.

Programmez une fonction qui énumère toutes les sous-listes d'une liste :

  1. let sl = sublists [1;2;3;4];;
  2. val sl : int list list =
  3. [[]; [4]; [3]; [3; 4]; [2]; [2; 4]; [2; 3]; [2; 3; 4]; [1]; [1; 4];
  4. [1; 3]; [1; 3; 4]; [1; 2]; [1; 2; 4]; [1; 2; 3]; [1; 2; 3; 4]]

Ajoutez une fonction énumérant toutes les bipartitions d'une liste :

  1. let bp = bipartitions [1;2;3;4];;
  2. val bp : (int list * int list) list =
  3. [([1; 2; 3; 4], []); ([2; 3; 4], [1]); ([1; 3; 4], [2]); ([3; 4], [1; 2]);
  4. ([1; 2; 4], [3]); ([2; 4], [1; 3]); ([1; 4], [2; 3]); ([4], [1; 2; 3]);
  5. ([1; 2; 3], [4]); ([2; 3], [1; 4]); ([1; 3], [2; 4]); ([3], [1; 2; 4]);
  6. ([1; 2], [3; 4]); ([2], [1; 3; 4]); ([1], [2; 3; 4]); ([], [1; 2; 3; 4])]

Expressions arithmétiques.

Dans le jeu le compte est bon, seules quatre opérations arithmétiques sont autorisés : addition, soustraction, produit et division entière exacte (on ne peut diviser que si le reste est $0$). De plus, les résultats négatifs ou nuls sont interdits.

On définit le type algébrique :

  1. type operator =
  2. | Plus
  3. | Minus
  4. | Prod
  5. | Div

Donnez une fonction qui à chaque opérateur, associe la fonction arithmétique qui lui correspond. La fonction retourne une option, afin de gérer les cas non-valides (résultat négatif ou non-entier).

Donnez une fonction qui à chaque opérateur lui associe son score entier : 20 pour l'addition, 40 pour la soustraction, 50 pour la multiplication et 100 pour la division. Le score sera utilisée pour évaluer la simplicité d'une expression: plus un score est faible, plus l'expression est simple.

Mettez le tout dans un module, dont voici l'interface.

  1. module Operator : sig
  2. type t =
  3. | Plus
  4. | Minus
  5. | Prod
  6. | Div

  7. val to_fun : t -> int -> int -> option int
  8. val get_score : t -> int
  9. end

Utilisez Operator.t pour définir le type des expressions arithmétiques, avec des litéraux entiers et des opérateurs arithmétiques.

Ajoutez une fonction d'évaluation pour obtenir la valeur entière d'une expression arithmétique. Le résultat est une option puisque l'expression peut-être mal-définie.

Créez un prédicat déterminant si une expression est valide.

La valeur d'une expression est la somme des scores de ces opérateurs, plus la somme de ces litéraux. Définissez une fonction calculant le score d'une expression.

Définissez une fonction qui étant donnée deux expressions, calcule la liste des expressions formable avec ces deux expressions plus un opérateur.

Ajoutez une fonction de formattage pour les expressions arithmétiques.

Vous obtiendrez le module (faites-en un fichier expr.ml) :

  1. module Expr : sig
  2. type t

  3. val get_value : t -> int option

  4. val is_valid : t -> bool

  5. val get_score : t -> int

  6. val combine : t -> t -> t list

  7. val pp : Format.formatter -> t -> unit
  8. end

Résolution.

Le principe de l'algorithme de résolution est le suivant :

  • on génère toutes les bipartitions de la liste d'entiers,
  • pour chaque bipartition, on génère récursivement toutes les valeurs constructibles avec les entiers de chacune des deux parties,
  • on teste toutes les combinaisons d'une valeur de la partie droite avec une valeur de la partie gauche,
  • on garde pour chaque valeur obtenue celle de plus petit score.

Les solutions seront stockés dans un dictionnaire d'entier : à $n$ on associe la combinaison de plus faible score permettant d'atteindre $n$. Pour toujours garder la combinaison de plus faible score, nous modifions la fonction d'insertion : on n'ajoute une combinaison que si son score est meilleure que la combinaison précédemment trouvée.

Donnez donc la fonction d'insertion d'une expression dans le dictionnaire :

  1. val add_expression : Expr.t IntMap.t -> Expr.t -> Expr.t IntMap.t

Définissez une fonction récursive qui, à une liste d'entiers, construit le dictionnaire qui à chaque objectif entier associe l'expression de plus petit score utilisant tous les entiers de la liste.

  1. val solve : int list -> Expr.t IntMap.t

Cette version n'est pas très efficace, car on recalcule sans cesse les mêmes sous-listes de la liste initiale. Pour cela, il faut utiliser la programmation dynamique. Il faut garder pour chaque sous-liste possible le dictionnaire de ces solutions. Autrement dit, il faut mémoïser la fonction de résolution. Puisque les instances sont des listes d'entiers, il faut un dictionnaire de liste d'entiers pour stocker les solutions. Créez donc la version mémoïsée :

  1. type memory : Expr.t IntMap.t IntListMap.t
  2. val solve :
  3. memoized:memory -> int list -> (Expr.t IntMap.t * memory)

Si la liste est déjà dans la mémoire, on retourne la solution déjà connue, sinon on la calcule et on l'ajoute à la mémoire.

Génération aléatoire d'instances.

Une instance est composée d'un objectif et de six entiers choisis parmi la liste

$$[ 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 25; 50; 75; 100]$$

L'objectif est un entier entre 100 et 999, tiré uniformément aléatoirement.

Définissez une fonction pour choisir uniformément $k$ éléments dans une liste de $n$ éléments (quelle est la probabilité d'avoir le premier élément dans cette liste ?).

Ajoutez ensuite une fonction générant aléatoirement des instances du compte est bon.

À vous de jouer...

  • Le solveur actuel utilise tous les entiers de la liste. Le jeu autorise à n'utiliser qu'une partie des entiers. Comment modifier le solveur pour cela ?
  • Définissez une fonction permettant de lire une expression arithmétique.
  • Créez une interface interactive en ligne de commande pour pouvoir jouer au jeu.
  • Dans la résolution, on considère souvent des expressions équivalentes, comme $a+b$ et $b+a$. Ajoutez des tests pour éviter le plus possible de tester des cas symétriques.