关键工具:熵、贪婪、分支定界; 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 个!