Outils clés : entropie, avidité, branchement et liaison ; Python, générateurs, itertools, modèle décoratif et non décoratif.
En répondant à cette question, j'ai voulu construire un langage de fonctions utiles pour explorer le problème. Je vais passer en revue ces fonctions, en les décrivant et en précisant leur but. À l'origine, ces fonctions disposaient d'une documentation complète, avec de petits tests unitaires intégrés testés à l'aide de doctest ; je ne saurais trop louer cette méthodologie, qui constitue un moyen brillant de mettre en œuvre le développement piloté par les tests. Cependant, elle ne se traduit pas bien sur StackOverflow, donc je ne la présenterai pas de cette façon.
Tout d'abord, j'aurai besoin de plusieurs modules standard et futur (je travaille avec Python 2.6).
from __future__ import division # No need to cast to float when dividing
import collections, itertools, math
J'aurai besoin d'une fonction de notation. A l'origine, cela retournait un tuple (noirs, blancs), mais j'ai trouvé la sortie un peu plus claire si j'utilisais un namedtuple :
Pegs = collections.namedtuple('Pegs', 'black white')
def mastermindScore(g1,g2):
matching = len(set(g1) & set(g2))
blacks = sum(1 for v1, v2 in itertools.izip(g1,g2) if v1 == v2)
return Pegs(blacks, matching-blacks)
Pour rendre ma solution générale, je passe tout ce qui est spécifique au problème Mastermind comme arguments de mots-clés. J'ai donc créé une fonction qui crée ces arguments une fois, et j'utilise la syntaxe **kwargs pour la faire circuler. Cela me permet également d'ajouter facilement de nouveaux attributs si j'en ai besoin plus tard. Notez que je permets aux devinettes de contenir des répétitions, mais contraint l'adversaire à choisir des couleurs distinctes ; pour changer cela, il suffit de changer G ci-dessous. (Si je voulais autoriser les répétitions dans le secret de l'adversaire, je devrais également modifier la fonction de notation).
def mastermind(colours, holes):
return dict(
G = set(itertools.product(colours,repeat=holes)),
V = set(itertools.permutations(colours, holes)),
score = mastermindScore,
endstates = (Pegs(holes, 0),))
def mediumGame():
return mastermind(("Yellow", "Blue", "Green", "Red", "Orange", "Purple"), 4)
Avant de me lancer dans la solution proprement dite, j'aurai besoin de quelques outils à usage général pour clarifier le code. A multiset est comme un ensemble, sauf que les valeurs peuvent apparaître plusieurs fois. Mon implémentation bon marché utilise simplement un dict qui met en correspondance les clés avec le nombre de fois où ces clés sont présentes :
def multiset(vals):
multiset = collections.defaultdict(int)
for val in vals: multiset[val] += 1
return multiset
Deuxièmement, parfois j'ai besoin de partition un ensemble basé sur le résultat de l'application d'une fonction à chaque élément de l'ensemble. Par exemple, les nombres 1..10 peuvent être partitionnés en nombres pairs et impairs par la fonction n % 2 (les impairs donnent 1, les pairs donnent 0). La fonction suivante renvoie une telle partition, mise en œuvre sous la forme d'une carte du résultat de l'appel de la fonction à l'ensemble des éléments qui ont donné ce résultat (par exemple, { 0 : pairs, 1 : impairs }).
def partition(S, func, *args, **kwargs):
partition = collections.defaultdict(set)
for v in S: partition[func(v, *args, **kwargs)].add(v)
return partition
J'ai décidé d'explorer un solveur qui utilise un approche gourmande et entropique . À chaque étape, il calcule l'information qui pourrait être obtenue à partir de chaque hypothèse possible, et sélectionne l'hypothèse la plus informative. À mesure que le nombre de possibilités augmente, cette méthode sera mal adaptée (quadratiquement), mais essayons-la ! Tout d'abord, j'ai besoin d'une méthode pour calculer l'entropie (information) d'un ensemble de probabilités. Il s'agit simplement de -∑p log p. Cependant, pour des raisons de commodité, je vais autoriser des entrées qui ne sont pas normalisées, c'est-à-dire dont la somme n'est pas égale à 1 :
def entropy(P):
total = sum(P)
return -sum(p*math.log(p, 2) for p in (v/total for v in P if v))
Alors comment vais-je utiliser cette fonction ? Eh bien, pour un ensemble de possibilités donné, V, et une estimation donnée, g, les informations que nous obtenons de cette estimation ne peuvent provenir que de la fonction de notation : plus précisément, de la façon dont cette fonction de notation divise notre ensemble de possibilités. Nous voulons faire une proposition qui distingue le mieux les possibilités restantes, c'est-à-dire qui les divise en un grand nombre de petits ensembles, car cela signifie que nous sommes beaucoup plus proches de la réponse. C'est exactement ce que la fonction d'entropie ci-dessus chiffre : un grand nombre de petits ensembles obtiendra un score plus élevé qu'un petit nombre de grands ensembles. Tout ce que nous avons à faire, c'est de le mettre à plat.
def decisionEntropy(V, g, score):
return entropy(multiset(score(gi, g) for gi in V).values())
Bien sûr, à chaque étape, nous aurons en fait un ensemble de possibilités restantes, V, et un ensemble de suppositions possibles que nous pourrions faire, G, et nous devrons choisir la supposition qui maximise l'entropie. De plus, si plusieurs suppositions ont la même entropie, il faut préférer en choisir une qui pourrait aussi être une solution valide ; cela garantit que l'approche se terminera. Pour ce faire, j'utilise le modèle standard python decorate-undecorate ainsi que la méthode max intégrée :
def bestDecision(V, G, score):
return max((decisionEntropy(V, g, score), g in V, g) for g in G)[2]
Il ne me reste plus qu'à appeler cette fonction de manière répétée jusqu'à ce que le bon résultat soit deviné. J'ai passé en revue un certain nombre d'implémentations de cet algorithme jusqu'à ce que j'en trouve une qui me semble correcte. Plusieurs de mes fonctions voudront aborder cette question de différentes manières : certaines énumèrent toutes les séquences possibles de décisions (une par supposition que l'adversaire a pu faire), tandis que d'autres ne sont intéressées que par un seul chemin à travers l'arbre (si l'adversaire a déjà choisi un secret, et que nous essayons simplement d'atteindre la solution). Ma solution est un "arbre paresseux", où chaque partie de l'arbre est un générateur qui peut être évalué ou non, permettant à l'utilisateur d'éviter des calculs coûteux dont il n'aura pas besoin. J'ai également fini par utiliser deux namedtuples supplémentaires, toujours pour la clarté du code.
Node = collections.namedtuple('Node', 'decision branches')
Branch = collections.namedtuple('Branch', 'result subtree')
def lazySolutionTree(G, V, score, endstates, **kwargs):
decision = bestDecision(V, G, score)
branches = (Branch(result, None if result in endstates else
lazySolutionTree(G, pV, score=score, endstates=endstates))
for (result, pV) in partition(V, score, decision).iteritems())
yield Node(decision, branches) # Lazy evaluation
La fonction suivante évalue un seul chemin dans cet arbre, sur la base d'une fonction de notation fournie :
def solver(scorer, **kwargs):
lazyTree = lazySolutionTree(**kwargs)
steps = []
while lazyTree is not None:
t = lazyTree.next() # Evaluate node
result = scorer(t.decision)
steps.append((t.decision, result))
subtrees = [b.subtree for b in t.branches if b.result == result]
if len(subtrees) == 0:
raise Exception("No solution possible for given scores")
lazyTree = subtrees[0]
assert(result in endstates)
return steps
On peut maintenant l'utiliser pour construire un jeu interactif de Mastermind où l'utilisateur note les suppositions de l'ordinateur. En jouant avec ce jeu, on découvre des choses intéressantes. Par exemple, la première réponse la plus informative est de la forme (jaune, jaune, bleu, vert) et non (jaune, bleu, vert, rouge). On obtient des informations supplémentaires en utilisant exactement la moitié des couleurs disponibles. Cela vaut également pour le jeu Mastermind à 3 trous à 6 couleurs - (jaune, bleu, vert) - et le jeu Mastermind à 5 trous à 8 couleurs - (jaune, jaune, bleu, vert, rouge).
Mais il reste encore de nombreuses questions auxquelles il n'est pas facile de répondre avec un solveur interactif. Par exemple, quel est le nombre maximum d'étapes nécessaires à l'approche entropique gloutonne ? Et combien d'entrées nécessitent autant d'étapes ? Pour faciliter la réponse à ces questions, je produis d'abord une fonction simple qui transforme l'arbre paresseux ci-dessus en un ensemble de chemins à travers cet arbre, c'est-à-dire, pour chaque secret possible, une liste de suppositions et de scores.
def allSolutions(**kwargs):
def solutions(lazyTree):
return ((((t.decision, b.result),) + solution
for t in lazyTree for b in t.branches
for solution in solutions(b.subtree))
if lazyTree else ((),))
return solutions(lazySolutionTree(**kwargs))
Trouver le pire cas est une simple question de trouver la solution la plus longue :
def worstCaseSolution(**kwargs):
return max((len(s), s) for s in allSolutions(**kwargs)) [1]
Il s'avère que ce solveur se termine toujours en 5 étapes ou moins. Cinq étapes ! Je sais que lorsque je jouais à Mastermind dans mon enfance, je mettais souvent plus de temps que cela. Cependant, depuis que j'ai créé ce solveur et que j'ai joué avec, j'ai grandement amélioré ma technique, et 5 étapes est en effet un objectif réalisable même si vous n'avez pas le temps de calculer la supposition entropiquement idéale à chaque étape ;)
Quelle est la probabilité que le solveur prenne 5 étapes ? Va-t-il jamais finir en 1 ou 2 étapes ? Pour le savoir, j'ai créé une autre petite fonction simple qui calcule la distribution de la longueur de la solution :
def solutionLengthDistribution(**kwargs):
return multiset(len(s) for s in allSolutions(**kwargs))
Pour l'approche entropique gourmande, avec répétitions autorisées : 7 cas prennent 2 étapes ; 55 cas prennent 3 étapes ; 229 cas prennent 4 étapes ; et 69 cas prennent le maximum de 5 étapes.
Bien sûr, il n'y a aucune garantie que l'approche entropique gourmande minimise le nombre d'étapes dans le pire des cas. La dernière partie de mon langage polyvalent est un algorithme qui décide si oui ou non il y a des étapes à franchir. tout solutions pour une limite donnée dans le pire des cas. Cela nous permettra de savoir si l'entropie gourmande est idéale ou non. Pour ce faire, j'adopte une stratégie de branch-and-bound :
def solutionExists(maxsteps, G, V, score, **kwargs):
if len(V) == 1: return True
partitions = [partition(V, score, g).values() for g in G]
maxSize = max(len(P) for P in partitions) ** (maxsteps - 2)
partitions = (P for P in partitions if max(len(s) for s in P) <= maxSize)
return any(all(solutionExists(maxsteps-1,G,s,score) for l,s in
sorted((-len(s), s) for s in P)) for i,P in
sorted((-entropy(len(s) for s in P), P) for P in partitions))
Il s'agit sans aucun doute d'une fonction complexe, c'est pourquoi un peu plus d'explications sont nécessaires. La première étape consiste à partitionner les solutions restantes en fonction de leur score après une estimation, comme précédemment, mais cette fois nous ne savons pas quelle estimation nous allons faire, donc nous stockons toutes les partitions. Maintenant, nous pourrait Il serait possible d'effectuer une récursion dans chacun d'entre eux, en énumérant effectivement l'univers entier des arbres de décision possibles, mais cela prendrait un temps horriblement long. Au lieu de cela, j'observe que, si à ce stade il n'y a pas de partition qui divise les solutions restantes en plus de n ensembles, alors il ne peut y avoir de telle partition à aucune étape future non plus. Si nous avons encore k étapes, cela signifie que nous pouvons distinguer entre au plus n k-1 solutions avant d'être à court de suppositions (à la dernière étape, nous devons toujours deviner correctement). Ainsi, nous pouvons rejeter toutes les partitions qui contiennent un score associé à plus de ce nombre de solutions. Ce sont les deux lignes de code suivantes.
La dernière ligne de code effectue la récursion, en utilisant les fonctions any et all de Python pour plus de clarté, et en essayant d'abord les décisions à plus forte entropie pour espérer minimiser le temps d'exécution dans le cas positif. Il récursionne également dans la plus grande partie de la partition en premier, car c'est la plus susceptible d'échouer rapidement si la décision était mauvaise. Une fois de plus, j'utilise le modèle standard decorate-undecorate, cette fois-ci pour envelopper la fonction Python trié fonction.
def lowerBoundOnWorstCaseSolution(**kwargs):
for steps in itertools.count(1):
if solutionExists(maxsteps=steps, **kwargs):
return steps
En appelant solutionExists à plusieurs reprises avec un nombre croissant d'étapes, nous obtenons une limite inférieure stricte sur le nombre d'étapes nécessaires dans le pire des cas pour une solution Mastermind : 5 étapes. L'approche entropique gourmande est en effet optimale.
Par curiosité, j'ai inventé un autre jeu de devinette, que j'ai surnommé "twoD". Dans ce jeu, vous essayez de deviner une paire de chiffres ; à chaque étape, on vous dit si votre réponse est correcte, si les chiffres que vous avez devinés ne sont pas inférieurs aux chiffres correspondants dans le secret, et si les chiffres ne sont pas supérieurs.
Comparison = collections.namedtuple('Comparison', 'less greater equal')
def twoDScorer(x, y):
return Comparison(all(r[0] <= r[1] for r in zip(x, y)),
all(r[0] >= r[1] for r in zip(x, y)),
x == y)
def twoD():
G = set(itertools.product(xrange(5), repeat=2))
return dict(G = G, V = G, score = twoDScorer,
endstates = set(Comparison(True, True, True)))
Pour ce jeu, l'approche entropique avide a un pire cas de cinq étapes, mais il existe une meilleure solution possible avec un pire cas de quatre étapes, confirmant mon intuition que l'avidité myopique n'est idéale que par coïncidence pour Mastermind. Plus important encore, cela a montré la flexibilité de mon langage : toutes les mêmes méthodes fonctionnent pour ce nouveau jeu de devinette que pour Mastermind, ce qui me permet d'explorer d'autres jeux avec un minimum de codage supplémentaire.
Qu'en est-il des performances ? Évidemment, étant implémenté en Python, ce code ne sera pas d'une rapidité fulgurante. J'ai également abandonné certaines optimisations possibles au profit d'un code clair.
Une optimisation bon marché consiste à observer que, lors du premier coup, la plupart des suppositions sont fondamentalement identiques : (jaune, bleu, vert, rouge) n'est pas vraiment différent de (bleu, rouge, vert, jaune), ou (orange, jaune, rouge, violet). Cela réduit considérablement le nombre de suppositions que nous devons prendre en compte lors de la première étape - autrement la décision la plus coûteuse du jeu.
Cependant, en raison du taux de croissance élevé de ce problème, je n'ai pas été en mesure de résoudre le problème Mastermind à 8 couleurs et 5 trous, même avec cette optimisation. Au lieu de cela, j'ai porté les algorithmes en C++, en gardant la même structure générale et en utilisant des opérations bitwise pour améliorer les performances dans les boucles internes critiques, pour une accélération de plusieurs ordres de grandeur. Je laisse au lecteur le soin de faire cet exercice :)
7 votes
Créer un blog peut-être ?
3 votes
@shoosh : vous pouvez être un peu plus gentil que ça :D @chrispy : j'apprécie totalement ce que vous faites en postant un tutoriel sur SO. J'aime l'idée, et d'autres sites ont des sections de tutoriel étendues. En fait, j'ai essayé de poster des tutoriels ici, mais les gens me disent généralement de ne pas le faire, SO est mieux pour les questions et réponses. Votre tutoriel obtiendra plus de vues sur un site différent pour les tutoriels qu'ici en tant que question probablement. Je recommanderais dreamincode.net pour poster des tutoriels :D (mais c'est à vous de voir, faites ce que vous voulez).
0 votes
@CrazyJugglerDrummer : Je ne dirais pas qu'il y aurait nécessairement moins de visites - si quelqu'un tape dans Google "comment résoudre mastermind" (ce qui serait probablement un terme de recherche assez courant pour ce problème), cette question se classe sur la première page - pas trop mal. S'il affine sa recherche en tapant "comment résoudre mastermind en python", cette question se classe en tête de liste. Comme le site est régulièrement bien classé, les didacticiels postés ici sont bien placés dans les résultats de recherche.
1 votes
Lorsque j'étudiais, je me souviens d'un camarade de classe qui construisait l'adversaire maléfique de Mastermind jouant l'"autre" joueur de Mastermind. Ainsi, au lieu de présélectionner une position cachée selon les règles, il triche et répond dynamiquement afin de maximiser le nombre de suppositions nécessaires, en choisissant au hasard parmi les pires alternatives. Le solveur généralisé devrait-il être capable de gérer cela également ?
0 votes
@hans-olsson Oui, veuillez couvrir cela avec une analyse standard du pire cas.
0 votes
Très bonne question, qui n'a malheureusement reçu aucune solution valable ou applicable. Soit le code n'est pas sous une forme ou en un seul morceau qui peut être testé, soit, comme cela arrive dans la plupart des réponses, la séquence cible (solution) est comparée et utilisée pour fournir des suppositions ! Ce n'est pas correct, du tout ! Dans tous les jeux de MM, la cible est censée être inconnue !
0 votes
@Apostolos C'est ainsi que fonctionne MasterMind : vous faites une supposition, puis vous recevez un "indice" qui vous indique à quel point vous êtes loin de la réponse, sous la forme de chevilles noires et blanches. Vous ne pouvez pas marquer sans la réponse !
0 votes
Voyez comment cela fonctionne lorsque l'ordinateur est le briseur de codes, ici : nebupookins.github.io/JS-Mastermind-Solver - Seulement que c'est en JavaScript ... Je ne trouve nulle part quelque chose de similaire en Python !
0 votes
Votre solveur utilise une première estimation sous-optimale, @Apostolos.
0 votes
@shoosh Just found stackoverflow.blog/2011/07/01/ :D