【问题标题】:How to solve the "Mastermind" guessing game?如何解决“主谋”猜谜游戏?
【发布时间】:2010-11-14 05:05:41
【问题描述】:

您将如何创建一个算法来解决以下难题“Mastermind”?

你的对手从一组六种颜色中选择了四种不同的颜色(黄色、蓝色、绿色、红色、橙色、紫色)。你必须猜出他们选择了哪些,以及以什么顺序。每次猜测后,您的对手会告诉您有多少(但不是哪种)您猜到的颜色在正确的位置是正确的 [“黑色”],以及有多少(但不是哪种)是正确的颜色但在错误的位置[“白人”]。当您猜对时游戏结束(4 个黑人,0 个白人)。

例如,如果您的对手选择了(蓝色、绿色、橙色、红色),而您猜测(黄色、蓝色、绿色、红色),您将得到一个“黑色”(代表红色)和两个白色(蓝色和绿色)。你会得到相同的猜测分数(蓝色、橙色、红色、紫色)。

我对您会选择哪种算法以及(可选)如何将其转换为代码(最好是 Python)感兴趣。我对以下编码解决方案感兴趣:

  1. 清晰(通俗易懂)
  2. 简洁
  3. 高效(快速猜测)
  4. 有效(解决难题的猜测次数最少)
  5. 灵活(可以轻松回答有关算法的问题,例如最坏的情况是什么?)
  6. 一般(可以轻松适应除 Mastermind 之外的其他类型的谜题)

我对一种非常有效但效率不高的算法感到满意(前提是它不仅实施不善!);然而,一个非常有效和有效的算法,不灵活且难以理解地实现是没有用的。

我已经发布了我自己的(详细)Python 解决方案,但这绝不是唯一或最好的方法,所以请发布更多!我不期待一篇论文;)

【问题讨论】:

  • 写个博客吧?
  • @shoosh:你可以比这更好一点。 :D @chrispy:我非常感谢您在发布有关 SO 的教程时所做的工作。我喜欢这个想法,其他网站也有大量的教程部分。我实际上已经尝试过在这里发布 tuts,但人们通常会告诉我不要这样做,这样更适合 Q 和 A。我将在 meta 上解决这个问题。您的教程可能会在不同的教程网站上获得比这里更多的视图作为一个问题。我会推荐 dreamincode.net 发布教程。 :D(但由你决定,随心所欲)
  • @CrazyJugglerDrummer:我不会说这必然会减少观看次数 - 如果有人在谷歌上输入“如何解决策划者”(这可能是一个相当常见的搜索词问题)比这个问题排在第一页 - 不太破旧。如果他们将搜索词细化为“how to solve mastermind in python”,那么这个问题就是排名靠前的结果——因为该网站的排名一直很高,因此发布在此处的教程在搜索结果中的排名也会很高。
  • 学习的时候记得有一个同学在构造邪恶的主谋对手,在主谋中扮演“其他”玩家。因此,它不是根据规则预先选择隐藏位置,而是作弊并动态回答,以最大限度地增加所需猜测的次数;在最差的选项中随机选择。广义求解器是否也能处理这个问题?
  • @hans-olsson 是的,请用标准的最坏情况分析来说明这一点

标签: python algorithm


【解决方案1】:

关键工具:熵、贪婪、分支定界; Python、生成器、迭代工具、装饰-取消装饰模式

在回答这个问题时,我想建立一种有用函数的语言来探索这个问题。我将介绍这些功能,描述它们及其意图。最初,这些有大量文档,使用 doctest 测试了小型嵌入式单元测试;我不能高度赞扬这种方法作为实现测试驱动开发的绝妙方法。但是,它不能很好地转换为 StackOverflow,因此我不会以这种方式呈现。

首先,我需要几个标准模块和未来导入(我使用 Python 2.6)。

from __future__ import division # No need to cast to float when dividing
import collections, itertools, math

我需要一个评分功能。最初,这返回了一个元组(黑人,白人),但如果我使用命名元组,我发现输出会更清晰一些:

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)

为了使我的解决方案具有通用性,我将特定于 Mastermind 问题的任何内容作为关键字参数传递。因此,我创建了一个创建这些参数一次的函数,并使用 **kwargs 语法来传递它。这也使我可以在以后需要时轻松添加新属性。请注意,我允许猜测包含重复,但会限制对手选择不同的颜色;要改变这一点,我只需要改变下面的 G。 (如果我想在对手的秘密中允许重复,我也需要更改评分功能。)

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)

有时我需要根据对集合中的每个元素应用函数的结果对集合进行分区。例如,数字 1..10 可以通过函数 n % 2 分为偶数和奇数(奇数为 1,偶数为 0)。以下函数返回这样一个分区,实现为从函数调用结果到给出该结果的元素集的映射(例如 {0: evens, 1: odds })。

def partition(S, func, *args, **kwargs):
  partition = collections.defaultdict(set)
  for v in S: partition[func(v, *args, **kwargs)].add(v)
  return partition

我决定探索一个使用贪婪熵方法的求解器。在每一步,它都会计算可以从每个可能的猜测中获得的信息,并选择信息量最大的猜测。随着可能性数量的增加,这将严重(二次)扩展,但让我们试一试!首先,我需要一种方法来计算一组概率的熵(信息)。这只是-∑p log p。但是,为了方便起见,我将允许未标准化的输入,即加起来不等于 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))

那么我将如何使用这个功能呢?好吧,对于给定的一组可能性 V 和给定的猜测 g,我们从该猜测中获得的信息只能来自评分函数:更具体地说,该评分函数如何划分我们的一组可能性。我们想做出一个能在剩余可能性中最好地区分的猜测——将它们分成最大数量的小集合——因为这意味着我们离答案更近了。这正是上面的熵函数给出的数字:大量的小集合将得分高于少量的大集合。我们需要做的就是将其插入。

def decisionEntropy(V, g, score):
  return entropy(collections.Counter(score(gi, g) for gi in V).values())

当然,在任何给定步骤,我们实际上将拥有一组剩余的可能性 V 和一组我们可以做出的可能猜测 G,我们需要选择使熵最大化的猜测。此外,如果多个猜测具有相同的熵,则更愿意选择一个也可能是有效解决方案的猜测;这保证了该方法将终止。我使用标准的 python decorate-undecorate 模式和内置的 max 方法来做到这一点:

def bestDecision(V, G, score):
  return max((decisionEntropy(V, g, score), g in V, g) for g in G)[2]

现在我需要做的就是重复调用这个函数,直到猜到正确的结果。我经历了该算法的许多实现,直到找到一个似乎正确的实现。我的几个函数会想以不同的方式来处理这个问题:一些会列举所有可能的决策序列(每个猜测对手可能已经做出),而另一些只对通过树的单个路径感兴趣(如果对手已经选择一个秘密,我们只是想找到解决方案)。我的解决方案是“惰性树”,其中树的每个部分都是可以评估或不评估的生成器,允许用户避免他们不需要的昂贵计算。最后我还使用了另外两个命名元组,同样是为了代码的清晰。

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

以下函数根据提供的评分函数评估通过此树的单个路径:

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

这现在可用于构建 Mastermind 的交互式游戏,用户可以在其中对计算机的猜测进行评分。玩弄这个揭示了一些有趣的事情。例如,信息量最大的第一个猜测是(黄色、黄色、蓝色、绿色)形式,而不是(黄色、蓝色、绿色、红色)。通过使用正好一半的可用颜色可以获得额外的信息。这也适用于 6 色 3 孔 Mastermind(黄色、蓝色、绿色)和 8 色 5 孔 Mastermind(黄色、黄色、蓝色、绿色、红色)。

但仍有许多问题无法通过交互式求解器轻松回答。例如,贪婪熵方法所需的最多步骤数是多少?有多少输入需要这么多步骤?为了更容易回答这些问题,我首先生成了一个简单的函数,它将上面的惰性树转换为一组通过这棵树的路径,即对于每个可能的秘密,猜测和分数的列表。

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))

找到最坏的情况是找到最长解决方案的简单问题:

def worstCaseSolution(**kwargs):
  return max((len(s), s) for s in allSolutions(**kwargs)) [1]

事实证明,这个求解器总是会在 5 步或更少的时间内完成。五步!我知道,当我还是个孩子的时候,我玩 Mastermind 的时间往往比这更长。然而,自从创建了这个求解器并使用它之后,我的技术有了很大的提高,即使你没有时间计算每一步的熵理想猜测,5 步确实是一个可以实现的目标;)

求解器执行 5 个步骤的可能性有多大?它会在 1 步或 2 步内完成吗?为了找出答案,我创建了另一个简单的小函数来计算解的长度分布:

def solutionLengthDistribution(**kwargs):
  return collections.Counter(len(s) for s in allSolutions(**kwargs))

对于贪婪熵方法,允许重复:7 个案例分 2 个步骤; 55例分3步; 229例分4步; 69个案例最多需要5个步骤。

当然,不能保证贪婪熵方法可以最大限度地减少最坏情况的步数。我的通用语言的最后一部分是一个算法,它决定对于给定的最坏情况界限是否有 any 解决方案。这将告诉我们贪婪熵是否理想。为此,我采用了分支定界策略:

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))

这绝对是一个复杂的函数,所以需要更多解释。第一步是在猜测之后根据他们的分数对剩余的解决方案进行分区,就像以前一样,但是这次我们不知道我们要做出什么猜测,所以我们存储所有分区。现在我们可以只递归到其中的每一个,有效地枚举可能的决策树的整个宇宙,但这需要非常长的时间。相反,我观察到,如果此时没有将剩余解决方案划分为 n 组以上的分区,那么在以后的任何步骤中都不会存在这样的分区。如果我们还剩下 k 步,这意味着在我们用尽猜测之前,我们最多可以区分 nk-1 个解(在最后一步,我们必须始终正确猜测)。因此,我们可以丢弃任何包含映射到多个解决方案的分数的分区。这是接下来的两行代码。

最后一行代码进行递归,为了清楚起见,使用 Python 的任何和所有函数,并首先尝试最高熵的决策,以希望在肯定情况下最小化运行时间。它还首先递归到分区的最大部分,因为如果决策错误,这最有可能快速失败。再一次,我使用标准的 decorate-undecorate 模式,这次是为了包装 Python 的 sorted 函数。

def lowerBoundOnWorstCaseSolution(**kwargs):
  for steps in itertools.count(1):
    if solutionExists(maxsteps=steps, **kwargs):
      return steps

通过随着步数的增加而反复调用 solutionExists,我们得到了 Mastermind 解决方案在最坏情况下所需步数的严格下限:5 步。贪心熵方法确实是最优的。

出于好奇,我发明了另一种猜谜游戏,我给它起了个绰号“twoD”。在此,您尝试猜测一对数字;在每一步,你都会被告知你的答案是否正确,你猜的数字是否不小于秘密中的相应数字,以及数字是否不大于。

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)))

对于这个游戏,贪婪熵方法的最坏情况是 5 步,但最好的解决方案可能是最坏情况 4 步,这证实了我的直觉,即近视贪婪只是巧合地适合 Mastermind。更重要的是,这显示了我的语言是多么灵活:所有方法都适用于这个新的猜谜游戏,就像 Mastermind 一样,让我可以用最少的额外编码来探索其他游戏。

性能怎么样?显然,在 Python 中实现,这段代码不会很快。我还放弃了一些可能的优化以支持清晰的代码。

一个廉价的优化是观察到,在第一步中,大多数猜测基本上是相同的:(黄色,蓝色,绿色,红色)实际上与(蓝色,红色,绿色,黄色)或(橙色,黄色、红色、紫色)。这大大减少了我们在第一步需要考虑的猜测次数——否则这是游戏中代价最高的决定。

但是,由于这个问题的运行时增长率很大,即使进行了这种优化,我也无法解决 8 色 5 孔 Mastermind 问题。相反,我将算法移植到 C++,保持一般结构相同,并采用按位运算来提高关键内部循环的性能,从而将速度提高多个数量级。我把这个作为练习留给读者:)

附录,2018:事实证明,贪婪熵方法对于 8 色 4 孔 Mastermind 问题也不是最优的,当存在算法时,最坏情况的长度为 7 步最多需要 6 个!

【讨论】:

  • 阅读 collections.defaultdict 类。您的分区函数可以用 collections.defaultdict(set) 编写。您的多重集是 collections.defaultdict(int)。很好的解决方案。
  • 不错。感谢分享。 :) 也很高兴在 RosettaCode.org 上看到这个。
  • @hugh:关于 defaultdict 类的要点。我确实曾经使用过它,但分析显示它有点慢。它更清晰,所以我把它缝合在上面。谢谢。
  • 哇。这似乎是很多工作!多年前,一个晚上我花了几个小时在铅笔和纸上思考这个问题,当我终于意识到评分函数是对称的时,我觉得自己像个白痴。在顿悟之后,编码实际上非常简单。
【解决方案2】:

我的朋友正在考虑比较简单的案例 - 8 种颜色,没有重复,没有空白。

没有重复,不需要考虑最大熵,所有猜测都有相同的熵,第一次或随机猜测都可以正常工作。

这是解决该变体的完整代码:

# SET UP
import random
import itertools
colors = ('red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'ultra')

# ONE FUNCTION REQUIRED
def EvaluateCode(guess, secret_code):
    key = []
    for i in range(0, 4):
        for j in range(0, 4):
            if guess[i] == secret_code[j]:
                key += ['black'] if i == j else ['white']    
    return key

# MAIN CODE
# choose secret code
secret_code = random.sample(colors, 4)
print ('(shh - secret code is: ', secret_code, ')\n', sep='')
# create the full list of permutations
full_code_list = list(itertools.permutations(colors, 4))
N_guess = 0
while True:
    N_guess += 1
    print ('Attempt #', N_guess, '\n-----------', sep='')
    # make a random guess
    guess = random.choice(full_code_list)
    print ('guess:', guess)
    # evaluate the guess and get the key
    key = EvaluateCode(guess, secret_code)
    print ('key:', key)
    if key == ['black', 'black', 'black', 'black']:
        break
    # remove codes from the code list that don't match the key
    full_code_list2 = []
    for i in range(0, len(full_code_list)):
        if EvaluateCode(guess, full_code_list[i]) == key:
            full_code_list2 += [full_code_list[i]]
    full_code_list = full_code_list2
    print ('N remaining: ', len(full_code_list), '\n', full_code_list, '\n', sep='')
print ('\nMATCH after', N_guess, 'guesses\n')

【讨论】:

  • “所有的猜测都有相同的熵” 在第一轮,是的,但是之后呢?
【解决方案3】:

我曾经写过一个“Jotto”求解器,它本质上是带有文字的“Master Mind”。 (我们每个人选一个词,轮流猜对方的词,得分“正确”(准确)匹配和“其他地方”(正确的字母/颜色,但位置错误)。

解决这个问题的关键是要认识到评分函数是对称的。

换句话说,如果score(myguess) == (1,2) 那么我可以使用相同的score() 函数将我之前的猜测与任何其他可能性进行比较,并消除任何不给出完全相同分数的可能性。

举个例子:隐藏词(目标)是“score”……当前猜测是“fools”——分数是1,1(一个字母,'o',是“right on ”;另一个字母“s”是“其他地方”)。我可以消除“guess”这个词,因为 `score("guess")(针对 "fools")返回 (1,0)(最后的 's' 匹配,但没有其他匹配)。所以“猜测”这个词与“傻瓜”不一致,对某个未知词的分数给出了(1,1)的分数。

所以我现在可以遍历每五个字母的单词(或五种颜色/字母/数字等的组合),并消除任何不得分为 1,1 的“傻瓜”。在每次迭代中都这样做,你会很快收敛到目标上。 (对于五个字母的单词,我每次都能在 6 次内尝试……通常只有 3 或 4 次)。当然,只有 6000 个左右的“单词”,而且每次猜测都消除了近 95%。

注意:对于下面的讨论,我说的是五个字母的“组合”,而不是六种颜色的四个元素。相同的算法适用;但是,对于旧的“Master Mind”游戏,问题要小几个数量级......在经典的“Master Mind”程序中只有 1296 个彩色钉子组合(6**4),假设允许重复。导致收敛的推理线涉及一些组合:对于一个五元素目标(n = [(a,b) for a in range(5) for b in range(6) if a+b &lt;= 5],如果您感到好奇,可以查看所有这些可能的分数有 20 个。因此,我们希望任何随机有效的选择将有大约 5% 的机会与我们的分数相匹配……其他 95% 不会,因此将在每个得分猜测中被淘汰。这不考虑单词模式中可能的聚类,但现实世界的行为是对于文字来说已经足够接近,对于“Master Mind”规则来说肯定更接近。但是,在 4 个插槽中只有 6 种颜色,我们只有 14 个可能的非获胜分数,因此我们的收敛速度不是那么快)。

对于 Jotto 来说,两个小挑战是:生成一个好的世界列表(awk -f 'length($0)==5' /usr/share/dict/words 或在 UNIX 系统上类似)以及如果用户选择了一个不在我们字典中的单词该怎么办(生成每个字母组合,' aaaaa' 到 'zzzzz' --- 即 26 ** 5 ... 或约 110 万)。 Python 中的一个简单的组合生成器大约需要 1 分钟来生成所有这些字符串……一个优化的应该要好得多。 (我还可以添加一个要求,即每个“单词”至少有一个元音......但这个约束没有多大帮助 --- 5 个元音 * 5 个可能的位置,然后乘以 26 ** 4 个其他组合) .

对于 Master Mind,您使用相同的组合生成器......但只有 4 或 5 个“字母”(颜色)。每个 6 色组合(其中 15,625 个)都可以在一秒钟内生成(使用与我上面使用的相同的组合生成器)。

如果我今天正在编写这个“Jotto”程序,例如在 Python 中,我会“作弊”,让一个线程在后台生成所有字母组合,同时我仍然从字典中删除单词(而我的对手是给我打分,猜测等)。当我生成它们时,我也会消除迄今为止的所有猜测。因此,在我消除所有已知单词之后,我将拥有一个相对较小的可能性列表,并且与人类玩家相比,我通过与他们的输入并行执行来“隐藏”我的大部分计算滞后。 (而且,如果我编写了这样一个程序的 Web 服务器版本,我会让我的 Web 引擎与本地守护程序对话,以请求与一组分数一致的序列。守护程序将保留本地生成的所有字母组合列表和将使用select.select() 模型将可能性反馈给游戏的每个正在运行的实例 --- 每个都将反馈我的守护进程单词/分数对,我的守护进程将应用它们作为它反馈给该客户端的可能性的过滤器) .

(相比之下,大约 20 年前,我在 XT 上使用 Borland TurboPascal 编写了我的“Jotto”版本......它可以进行每次消除迭代 --- 从它编译成几千个单词的列表开始 -- - 不到一秒钟。我通过编写一个简单的字母组合生成器(见下文)来构建它的单词列表......将结果保存到一个中等大小的文件中,然后使用宏运行我的文字处理器的拼写检查以删除所有内容那是“拼写错误”---然后我使用另一个宏将所有剩余的行包装在正确的标点符号中,以使它们对我的数组有效静态分配,这是我程序的#include文件。所有这些让我构建一个独立的游戏程序,几乎“知道”每一个有效的英语 5 字母单词;该程序是一个 .COM --- 如果我没记错的话,小于 50KB)。

由于其他原因,我最近用 Python 编写了一个简单的任意组合生成器。它大约有 35 行代码,我已将其发布到我在 bitbucket.org 上的“trite sn-ps”wiki ......它不是 Python 意义上的“生成器”......而是一个可以实例化为无限的类元素的“数字”或“符号”组合序列(基本上以任何正整数为基数)。

您可以在以下位置找到它:Trite Snippets: Arbitrary Sequence Combination Generator

对于我们score() 函数的精确匹配部分,您可以使用这个:

def score(this, that):
    '''Simple "Master Mind" scoring function'''
    exact = len([x for x,y in zip(this, that) if x==y])
    ### Calculating "other" (white pegs) goes here:
    ### ...
    ###
    return (exact,other)

我认为这体现了 Python 的一些美丽:zip() 两个序列, 返回任何匹配的,并取结果的长度)。

在“其他”位置查找匹配项看似更复杂。如果不允许重复,那么您可以简单地使用集合来查找交叉点。

[在我之前对该消息的编辑中,当我意识到如何使用zip() 进行精确匹配时,我错误地认为我们可以使用other = len([x for x,y in zip(sorted(x), sorted(y)) if x==y]) - exact 逃脱......但是已经很晚了,我很累。当我睡在上面时,我意识到这个方法有缺陷。 糟糕,吉姆!不要在没有充分测试的情况下发帖!*(测试了几个碰巧有效的案例)]。

过去我使用的方法是对两个列表进行排序,比较每个列表的头:如果头相等,则增加计数并从两个列表中弹出新项目。否则将一个新值弹出到两个头中较小的那个,然后重试。任一列表为空时立即中断。

这确实有效;但它相当冗长。使用这种方法我能想到的最好的就是十几行代码:

other = 0
x = sorted(this)   ## Implicitly converts to a list!
y = sorted(that)
while len(x) and len(y):
    if x[0] == y[0]:
        other += 1
        x.pop(0)
        y.pop(0)
    elif x[0] < y[0]:
        x.pop(0)
    else:
        y.pop(0)
other -= exact

使用字典我可以将其减少到大约九个:

other = 0
counters = dict()
for i in this:
    counters[i] = counters.get(i,0) + 1
for i in that:
    if counters.get(i,0) > 0:
        other += 1
        counters[i] -= 1
other -= exact

(使用新的“collections.Counter”类(Python3 并计划用于 Python 2.7?)我大概可以再减少一点;这里的三行代码正在初始化 counters 集合)。

当我们找到匹配项时减少“计数器”很重要,并且在我们的测试中测试大于零的计数器也很重要。如果给定的字母/符号在“this”中出现一次,在“that”中出现两次,那么它只能算作一次匹配。

第一种方法写起来肯定有点棘手(必须小心避免边界)。同样在几个快速基准测试中(测试一百万对随机生成的字母模式),第一种方法比使用字典的方法花费的时间长约 70%。 (另一方面,使用random.shuffle() 生成数百万对字符串的时间是较慢的评分函数的两倍)。

对这两个功能的性能进行正式分析会很复杂。第一种方法有两种,所以是 2 * O(nlog(n)) ... 它至少遍历两个字符串中的一个,并且可能必须一直迭代到另一个字符串(最好的情况 O(n),最坏的情况 O(2n))——强制我在这里误用了大 O 表示法,但这只是一个粗略的估计。第二种情况完全取决于字典的性能特征。如果我们使用 b-trees,那么创建的性能大约为 O(nlog(n),从其中的另一个字符串中查找每个元素将是另一个 O(n*log(n)) 操作。但是, Python 字典非常高效,并且这些操作应该接近恒定时间(很少有哈希冲突)。因此我们预计性能大约为 O(2n) ...这当然简化为 O(n)。大致符合我的基准测试结果。

浏览关于“Master Mind”的 Wikipedia 文章,我发现 Donald Knuth 使用了一种与我的方法类似的方法(以及 10 年前),但他添加了一个重要的优化。在收集了所有剩余的可能性后,他选择了能在下一轮中消除最多可能性的那个。我考虑过对我自己的程序进行这样的改进,并出于实际原因拒绝了这个想法。在他的情况下,他正在寻找一个最佳(数学)解决方案。就我而言,我担心可玩性(在 XT 上,最好使用少于 64KB 的 RAM,尽管我可以切换到 .EXE 格式并使用最多 640KB)。我想将响应时间控制在 1 到 2 秒的范围内(这在我的方法中很容易,但在进一步的投机评分中会变得更加困难)。 (记住我在 Pascal 中工作,在 MS-DOS 下......没有线程,尽管我确实实现了对 UI 粗略异步轮询的支持,结果证明这是不必要的)

如果我今天要写这样的东西,我也会添加一个线程来做更好的选择。这将允许我给出我在特定时间限制内找到的最佳猜测,以保证我的玩家不必等待太长时间来等待我的猜测。我的选择/淘汰自然会在等待对手猜测的同时进行。

【讨论】:

    【解决方案4】:

    这里是 Mastermind(tm) 的纯 Python 求解器的链接:http://code.activestate.com/recipes/496907-mastermind-style-code-breaking/ 它有一个简单的版本、一种试验各种猜测策略的方法、性能测量和一个可选的 C 加速器。

    菜谱的核心是短而甜:

    import random
    from itertools import izip, imap
    
    digits = 4
    fmt = '%0' + str(digits) + 'd'
    searchspace = tuple([tuple(map(int,fmt % i)) for i in range(0,10**digits)])
    
    def compare(a, b, imap=imap, sum=sum, izip=izip, min=min):
        count1 = [0] * 10
        count2 = [0] * 10
        strikes = 0
        for dig1, dig2 in izip(a,b):
            if dig1 == dig2:
                strikes += 1
            count1[dig1] += 1
            count2[dig2] += 1
        balls = sum(imap(min, count1, count2)) - strikes
        return (strikes, balls)
    
    def rungame(target, strategy, verbose=True, maxtries=15):
        possibles = list(searchspace)
        for i in xrange(maxtries):
            g = strategy(i, possibles)
            if verbose:
                print "Out of %7d possibilities.  I'll guess %r" % (len(possibles), g),
            score = compare(g, target)
            if verbose:
                print ' ---> ', score
            if score[0] == digits:
                if verbose:
                    print "That's it.  After %d tries, I won." % (i+1,)
                break
            possibles = [n for n in possibles if compare(g, n) == score]
        return i+1
    
    def strategy_allrand(i, possibles):
        return random.choice(possibles)
    
    if __name__ == '__main__':
        hidden_code = random.choice(searchspace)
        rungame(hidden_code, strategy_allrand)
    

    这是输出的样子:

    Out of   10000 possibilities.  I'll guess (6, 4, 0, 9)  --->  (1, 0)
    Out of    1372 possibilities.  I'll guess (7, 4, 5, 8)  --->  (1, 1)
    Out of     204 possibilities.  I'll guess (1, 4, 2, 7)  --->  (2, 1)
    Out of      11 possibilities.  I'll guess (1, 4, 7, 1)  --->  (3, 0)
    Out of       2 possibilities.  I'll guess (1, 4, 7, 4)  --->  (4, 0)
    That's it.  After 5 tries, I won.
    

    【讨论】:

    • 恐怕有人已经在 2009 年发布了链接到您的食谱的答案!请参阅我对该帖子的评论以获取我的回复。
    • 我为 StackOverflow 简化了上面的演示。
    • 不过,您实际上并没有将其作为问题的解决方案提出来:(
    • 很有趣,但这不是MM游戏的解决方法。目标(解决方案)必须是未知,不提供比较和猜测!!
    【解决方案5】:

    这是我编写的一个通用算法,它使用数字来表示不同的颜色。易于更改,但我发现数字比字符串更容易使用。

    您可以随意使用该算法的任何整体或部分,只要相应地给予信用。

    请注意,我只是 12 年级计算机科学专业的学生,​​所以我敢打赌,肯定有更优化的解决方案可用。

    不管怎样,代码如下:

    import random
    
    
    def main():
    
        userAns = raw_input("Enter your tuple, and I will crack it in six moves or less: ")
        play(ans=eval("("+userAns+")"),guess=(0,0,0,0),previousGuess=[])
    
    def play(ans=(6,1,3,5),guess=(0,0,0,0),previousGuess=[]):
    
        if(guess==(0,0,0,0)):
           guess = genGuess(guess,ans)
        else:
            checker = -1
            while(checker==-1):
                guess,checker = genLogicalGuess(guess,previousGuess,ans)
    
        print guess, ans
    
    
        if not(guess==ans):
            previousGuess.append(guess)
    
            base = check(ans,guess)
    
            play(ans=ans,guess=base,previousGuess=previousGuess)
    
        else:
            print "Found it!"
    
    
    
    
    
    def genGuess(guess,ans):
        guess = []
        for i in range(0,len(ans),1):
            guess.append(random.randint(1,6))
    
        return tuple(guess)
    
    def genLogicalGuess(guess,previousGuess,ans):
        newGuess = list(guess)
        count = 0
    
        #Generate guess
    
        for i in range(0,len(newGuess),1):
            if(newGuess[i]==-1):
                newGuess.insert(i,random.randint(1,6))
                newGuess.pop(i+1)
    
    
        for item in previousGuess:
            for i in range(0,len(newGuess),1):
                if((newGuess[i]==item[i]) and (newGuess[i]!=ans[i])):
                    newGuess.insert(i,-1)
                    newGuess.pop(i+1)
                    count+=1
    
        if(count>0):
            return guess,-1
        else:
            guess = tuple(newGuess)
            return guess,0
    
    
    def check(ans,guess):
        base = []
        for i in range(0,len(zip(ans,guess)),1):
            if not(zip(ans,guess)[i][0] == zip(ans,guess)[i][1]):
                base.append(-1)
            else:
                base.append(zip(ans,guess)[i][1])
    
        return tuple(base)
    
    main()
    

    【讨论】:

      【解决方案6】:

      为了解决“最坏”的情况,我不是使用熵,而是寻找具有最大元素数量的分区,然后选择这个最大值的最小值 => 这会给我最小的数量当我不走运时(在最坏的情况下会发生),剩下的可能性。

      这总是在 5 次尝试中解决标准情况,但这并不能完全证明确实需要 5 次尝试,因为可能会发生对于下一步,更大的集合可能性会比更小的可能性给出更好的结果(因为更容易区分)。

      虽然对于 1680 的“标准游戏”,我有一个简单的正式证明: 第一步,为最大数量的分区提供最小值的尝试是 0,0,1,1:256。播放 0,0,1,2 不太好:276。 对于随后的每次尝试,都有 14 个结果(1 个未放置,3 个放置是不可能的),4 个放置的分区为 1。这意味着在最好的情况下(所有分区大小相同),我们将得到一个最大分区,即(可能性的数量 - 1)/13 的最小值(四舍五入,因为我们有整数,所以必然有一些会更小,而另一些会更多,因此最大值被四舍五入)。

      如果我应用这个:

      第一次播放 (0,0,1,1) 后,我还剩 256 个。

      第二次尝试后:20 = (256-1)/13

      第三次尝试后:2 = (20-1)/13

      那么我别无选择,只能尝试第四次尝试剩下的两个中的一个。

      如果我运气不好,需要第五次尝试。

      这证明我们至少需要尝试 5 次(但这还不够)。

      【讨论】:

        【解决方案7】:

        只是想我会贡献我的 90 多行代码。我以@Jim Dennis' 的回答为基础,主要消除了关于对称评分的提示。我已经实现了 Knuth 在Mastermind wikipedia article 中描述的极小极大算法,但有一个例外:我将下一步行动限制在当前可能的解决方案列表中,因为我发现在每一步都考虑所有可能的解决方案时性能严重下降。目前的方法给我留下了最坏的情况,即任何组合的 6 次猜测,每一次都在不到一秒的时间内找到。

        可能需要注意的是,我对隐藏序列没有任何限制,允许任意数量的重复。

        from itertools import product, tee
        from random import choice
        
        COLORS = 'red ', 'green', 'blue', 'yellow', 'purple', 'pink'#, 'grey', 'white', 'black', 'orange', 'brown', 'mauve', '-gap-'
        HOLES = 4
        
        def random_solution():
            """Generate a random solution."""
            return tuple(choice(COLORS) for i in range(HOLES))
        
        def all_solutions():
            """Generate all possible solutions."""
            for solution in product(*tee(COLORS, HOLES)):
                yield solution
        
        def filter_matching_result(solution_space, guess, result):
            """Filter solutions for matches that produce a specific result for a guess."""
            for solution in solution_space:
                if score(guess, solution) == result:
                    yield solution
        
        def score(actual, guess):
            """Calculate score of guess against actual."""
            result = []
            #Black pin for every color at right position
            actual_list = list(actual)
            guess_list = list(guess)
            black_positions = [number for number, pair in enumerate(zip(actual_list, guess_list)) if pair[0] == pair[1]]
            for number in reversed(black_positions):
                del actual_list[number]
                del guess_list[number]
                result.append('black')
            #White pin for every color at wrong position
            for color in guess_list:
                if color in actual_list:
                    #Remove the match so we can't score it again for duplicate colors
                    actual_list.remove(color)
                    result.append('white')
            #Return a tuple, which is suitable as a dictionary key
            return tuple(result)
        
        def minimal_eliminated(solution_space, solution):
            """For solution calculate how many possibilities from S would be eliminated for each possible colored/white score.
            The score of the guess is the least of such values."""
            result_counter = {}
            for option in solution_space:
                result = score(solution, option)
                if result not in result_counter.keys():
                    result_counter[result] = 1
                else:
                    result_counter[result] += 1
            return len(solution_space) - max(result_counter.values())
        
        def best_move(solution_space):
            """Determine the best move in the solution space, being the one that restricts the number of hits the most."""
            elim_for_solution = dict((minimal_eliminated(solution_space, solution), solution) for solution in solution_space)
            max_elimintated = max(elim_for_solution.keys())
            return elim_for_solution[max_elimintated]
        
        def main(actual = None):
            """Solve a game of mastermind."""
            #Generate random 'hidden' sequence if actual is None
            if actual == None:
                actual = random_solution()
        
            #Start the game of by choosing n unique colors
            current_guess = COLORS[:HOLES]
        
            #Initialize solution space to all solutions
            solution_space = all_solutions()
            guesses = 1
            while True:
                #Calculate current score
                current_score = score(actual, current_guess)
                #print '\t'.join(current_guess), '\t->\t', '\t'.join(current_score)
                if current_score == tuple(['black'] * HOLES):
                    print guesses, 'guesses for\t', '\t'.join(actual)
                    return guesses
        
                #Restrict solution space to exactly those hits that have current_score against current_guess
                solution_space = tuple(filter_matching_result(solution_space, current_guess, current_score))
        
                #Pick the candidate that will limit the search space most
                current_guess = best_move(solution_space)
                guesses += 1
        
        if __name__ == '__main__':
            print max(main(sol) for sol in all_solutions())
        

        如果有人发现对上述代码的任何可能的改进,我会对您的建议非常感兴趣。

        【讨论】:

        • 它看起来并且可能是一个有趣的 MM “求解器”(迄今为止最有希望的),但它仍然远非真正的 MM 求解器!它使用解决方案(“实际”)来生成猜测!而且,顺便说一句,这不是 5-6 次猜测......我们谈论的是千次(正如我通过调用 'score()' 函数的次数发现的。另外,我用另一个函数替换了这个函数,得到用户输入作为“回复”(通常应该如此)并以与“分数()”完全相同的形式返回它。好吧,猜猜看:它永远不会结束!猜测非常接近解决方案,然后他们移动走开!!
        【解决方案8】:

        【讨论】:

        • 不错的链接,谢谢。不幸的是,他的整个策略都建立在“每个 [猜测] 只对先前的假设进行最小可能的修改”的想法之上。 (我以前也是这样玩的。)我的求解器向我展示的一件事是,如果你坚持旧的假设,你得到的信息就会更少。最好尽可能频繁地挑战您的假设,以便从每次猜测中挤出最多的信息。
        • 好的 - 这是一个很好的观点。我一直在用他的方法有条不紊地解决它,但我现在也将开始寻找其他方法。
        【解决方案9】:

        你看起来像 Raymond Hettingers attempt 吗?它们肯定符合您的某些要求。

        我想知道他的解决方案与你的相比如何。

        【讨论】:

        • 不错的链接,谢谢!他也采用了熵方法,但采用了随机抽样。不幸的是,他的猜测也仅限于剩余的可能性,我最初所做的一些工作表明他最多限制在 6 个最小步骤。 (第一步的 4 个白人会给你带来很多麻烦,IIRC。)
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2022-11-01
        • 2015-04-15
        • 2020-11-22
        • 2014-05-12
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多