咳咳,提前说一句,本文章也发在b站,文章同名,作者就是本人。最初我是在哪里写的,所以看到我写的那些抽象中文和b站同款分界线不要太惊讶。(比那篇又加了一点点描述)
学艺不精,还请多多指教
最基础的猜数字游戏属于编程的入门级作品。输入一个值,输出偏大还是偏小。
而进阶一点的版本,就是所谓Bulls and Cows。
Bulls and Cows的答案由四个不重复的0到9的数字组成。每次输入一个值,输入数字对应位上正确的数量记为m,输入四位在答案中出现过但不在对应的位上的数量记为n。每次输入一个数字都会返回mAnB,修正后重新输入直到得到m=4,即与答案每一位都对应,就达到了游戏的目的。
为了方便理解,我举个例子。
(答案是:8 0 1 2)
第一次猜测:1 2 3 4 输出:0A 2B
说明有两个是对的,但是都不在正确的位置上
第二次猜测:2 3 8 0 输出:0A 3B
说明有三个答案被包含于这四个数字中,但都不在正确的位置上
第三次猜测:3 8 5 2 输出:1A 1B
一个位置正确,一个位置不正确
第四次猜测:0 8 2 1 输出:0A 4B
很幸运的,四个数字都正确,且都不在它们应该在的位置上,根据之前的猜测,只剩下8 0 1 2和8 1 0 2两种可能得结果
第五次猜测:8 0 1 2 输出:4A 0B
又一次的,幸运使然,你直接猜到了结果(二分之一几率)
成功猜到结果,循环结束
这本身是传统而有名的一款智力游戏,而本文旨在用Python实现(非图形化窗口)的猜数字游戏,并讨论计算机求解的算法。
目录
一、实现
游戏基础的实现十分简单。首先我们需要一个随机产生答案的函数:
import random
def get_new():
# 随机得到一个新数字
out = ""
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
for i in range(4):
j = random.randint(0, 9 - i)
out += str(nums[j])
del nums[j]
return out
然后就是判定一个输入的A和B的数量,思路就是把a和b的总数先算出来(在代码中即ab),然后把每位对应的(a的数量)刨去。
def check(inp, answer): # 检查输入的mAnB
a, b, ab = 0, 0, 0
if inp == answer:
return (4, 0)
#有几个a
for y in range(4):
if answer[y] == inp[y]:
a += 1
#有几个b
for i in inp:
if i in answer:
ab += 1
b = ab - a
return a, b
保护性编程,写一个判断输入是否合法的函数
def is_legal(inp): # 对输入和不合规定的判定
if len(inp) != 4:
return False
try: int(inp)
except: return False
for i in range(10):
if inp.count(str(i)) > 1:
return False
return True
然后就是游戏的主循环了!只有一些逻辑的部分。
def game():
x = True
while x:
answer = get_new() # 得到数字
times = 0 # 统计次数
inps, results = [], [] # 游戏过程记录
# 游戏信息
print("猜数字1A2B\n规则:\n 输入四位数猜测正确的四位数(四位都不重复),每输入一次的输出为xAxB\n A的数量表示数字正确位置正确\n B数量表示数字正确位置不正确")
print("输入‘q’游戏结束,输入‘r’重新开始")
print("看你几次能猜出来!")
# 猜数字循环
while True:
inp = input("> ")
if inp == "q": # 退出
x = False
break
elif inp == "r": # 重开
break
if not is_legal(inp): # 是否合法,不合法直接从下一次循环开始
print("不合法的输入")
continue
times += 1 # 合法输入才增加步数
inps.append(inp)
# mAnB判定
A, B = check(inp, answer)
results.append((A, B))
print("{}A{}B".format(A, B))
if A == 4:
print("恭喜你赢了!")
print_tellings(times)
print()
break
#结语
input("欢迎下次再来玩哦!(按回车退出)")
if __name__ == "__main__":
game()
其中封装了一些函数,比如 print_tellings(times),也就是分猜出来需要的步数输出提示语。
最后测试一下,非常正常的运行了:
某次运气爆炸的游戏记录
二、策略与具体求解
接下来是本文章的重头戏了!
可以看出,所有解法的一般过程如下(不同策略的差异在于指标不一样)
- 准备一个列表,包含所有可能的数字(共项,每项四个字节,需要的内存不大,处理起来也不算太困难),记为;复制一份作为可能是答案的范围,记为。
- 首先输入1234(输入什么都一样),得到第一次结果。
- 通过得到的结果,遍历列表,看看哪项不可能是答案(假设它是答案第一次就不是这个结果),并从中排除这些项目。
- 通过的剩余项目,用某一个指标对所有数字(即)进行打分,选择其中打分最高的作为输入,得到对应的结果
- 反复执行③-④,直到可能是答案的范围缩减到只有一项,这项必然是答案
可见对于这个游戏,它的实质是通过每一步来获取信息,通过信息修正我们的猜测,缩小可能答案的范围,最终达到答案的过程。可知最优策略就是在整个过程最大化、最高效地获取信息。然而达到这个目的十分困难,我们可以先退而求其次,计算当前这步如何才能获得最多的信息(也就是考虑局部最优)。因为信息的获取意味着不确定性的减少,也就是能排除掉数字的增加,于是考虑这步所有可能的的输入-输出组合,计算哪个输出能排除掉的数字平均来说数目有多少以作为④中的打分标准。
我cpu有点炸最后也没能实现(第二版编辑实现了),后来查资料得知这种方法被称为平均情况指标(Irving, 1978),也算是暗合前人了。
根据百度百科的数据,这种策略需要平均5.268次,属于较好的策略。
(由于我太菜了,导致这些策略我都不会写,我就只写了一个hint模块,也就是在可能的答案里边瞎选(也就是简单策略吧我猜(实际上不是)),和上边推理一点关系没有,如果有大神能写出来欢迎找我讨论啊。(对不起,我真的很菜))
def hint(inps, results):
if not inps:
return '1234'
while True: # 随机一个数字,假装它是答案,如果符合先前的结论就输出,不符合就再找一个
guess = get_new()
if maybe(guess, inps, results):
return guess
OK,上面是我上次写的,经过了数日的深思熟虑,我现在已经进化了!!!!!!!!!!!!!!!!!!!!!!!!!!
我把平均情况指标的代码完成了,下面看我表演
首先先写一个获取所有数字的代码:
def get_all(nums=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], repeat=4, parent="", ALL=[]):
# 得到所有可能的数字(递归,存在ALL内)
if repeat == 0:
ALL.append(parent)
else:
for i in range(len(nums)):
get_all(nums[:i] + nums[i + 1:], repeat - 1, parent + str(nums[i]), ALL)
代码是递归形式,只需要在ALL实参处填入一个列表,后续结果就会保存在其中。
然后我们再写一个根据结果排除不可能的答案的函数:
def excluse(L, inp, result): # 根据一条历史记录,输出从L删除不可能的答案之后的列表
out = [] # 输出
for x in L:
if check(inp, x) == result: # 检查L[j]是否有可能成为答案
out.append(x)
return out
def excluse_number(L, inp, result): # 根据一条历史记录,输出从L中可能的答案数量
out = 0 # 输出
for x in L:
if check(inp, x) == result: # 检查L[j]是否有可能成为答案
out += 1
return out
这里写了两个,第二个只计算数量,不需要操作列表。
然后最关键的,写一个函数,计算一个数字输入后平均能留下的答案数:
def average_exc(L, inp): # 输出若输入inp的平均剩余,L是可能集
out = 0
M = [(0, 0), (1, 0), (0, 1), (2, 0), (1, 1), (0, 2), (3, 0), (2, 1), (1, 2), (0, 3), (4, 0), (2, 2), (1, 3), (0, 4)] # 可能的输出
for r in M:
out += excluse_number(L, inp, r) ** 2 # leave/len(L) 发生该情况的可能性(排除剩余的答案在原先答案中的占比)
return out / len(L)
正如注释所言,因为结果r出现的概率是P=该结果出现后剩余可能答案 / 原先总的答案数量
故而平均剩余应该是。
那么每一步应该保证找到能排除最多的,也就是平均剩余量最小的,代码如下:
def get_best(L0, L): # L0全部数字,L可能是答案的的数字
inp = '1234'
best_ave = average_exc(L, '1234')
for a in L0: # 循环所有数字而不是可能的数字
ave = average_exc(L, a)
if ave == best_ave: # 当相等的时候,优先选择L内的数字
if a in L and not inp in L:
inp = a
elif ave < best_ave: # 平均剩余最小
best_ave = ave
inp = a
return inp
因此后面的事情就很简单了,写个逻辑即可:
def average_solver(answer, List=None): # 即选择产生答案排除答案平均最多的
if List is None:
L = []
get_all(ALL=L)
L0 = L[:]
else:
L = List[:]
inps = []
results = []
n = 1
while 1:
if len(L) > 1:
if n == 1:
inp = '1234'
else:
inp = get_best(L0, L)
inps.append(inp)
res = check(inp, answer)
L = excluse(L, inp, res)
results.append(res)
else:
inp = L[0]
inps.append(inp)
res = check(inp, answer)
results.append(res)
if res[0] == 4:
break
n += 1
return inps, results
但是经过测试,效率有点抽象(一分钟解一个数字?)
所以我索性把第一个数字强制定为1234,然后遍历的前两个数字的猜测存作数据,如下表
E1 = ['1234']
E2 = ['0567', '0256', '0325', '0135', '0235', '0145', '0135', '0135', '0135', '0145', '1234', '0124', '0123', '2341'] # 预计算结果
E3_0 = ['None', 'None', 'None', '0689', '0589', '5879', '0178', '0158', '0578', '5678', '0567', '0125', '0157', '5670']
E3_1 = ['1748', '0738', '1573', '5278', '0278', '1067', '0578', '0278', '5278', '1045', '0256', '0265', '5260', 'None']
E3_2 = ['6178', '0456', '4061', '0267', '6327', '2670', '0367', '0367', '5360', '2056', 'None', 'None', 'None', 'None']
E3_3 = ['6078', '6437', '2654', '0674', '5246', '6207', '0235', '0617', '1067', '1054', 'None', '1035', '1530', 'None']
E3_4 = ['1467', '6137', '1672', '5246', '2637', '1063', '0135', '2637', '2637', '1052', 'None', '0253', '2530', 'None']
E3_5 = ['2367', '6127', '2467', '5126', '3160', '3516', '0467', '4167', '6417', '4617', '0145', '0415', '0451', '4051']
E3_6 = ['None', '6017', '0167', '0234', '6217', '1204', 'None', '1235', '1034', 'None', 'None', 'None', 'None', 'None']
E3_7 = ['None', '0267', '1627', '6017', '1672', '5246', '0134', '1435', '1032', '1203', 'None', 'None', 'None', 'None']
E3_8 = ['None', '2436', '6241', '2167', '4036', '1367', '0132', '0431', '0431', '1543', 'None', 'None', 'None', 'None']
E3_9 = ['None', '2163', '4326', '2647', '3540', '4617', '0142', '3140', '0412', '2451', 'None', 'None', 'None', 'None']
E3_10 = ['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', '1234', 'None', 'None', 'None']
E3_11 = ['None', 'None', 'None', 'None', 'None', 'None', 'None', '1324', '3214', '0132', 'None', 'None', 'None', 'None']
E3_12 = ['None', 'None', 'None', 'None', 'None', 'None', 'None', '1423', '4132', '1342', 'None', 'None', 'None', 'None']
E3_13 = ['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', '2341', '2143', '2413', '3412']
E后面的数字表示第几步,下划线后的数字是第几组。上表出现None就说明这种情况会导致,即不存在这种可能性。
(以下是组的对应表)
# 0 1 2 3 4 5 6 7 8 9 10 11 12 13
M = [(0, 0), (1, 0), (0, 1), (2, 0), (1, 1), (0, 2), (3, 0), (2, 1), (1, 2), (0, 3), (4, 0), (2, 2), (1, 3), (0, 4)]
比如第一次猜测:1 2 3 4 输出:0A 2B,就对应第5组。
于是第二次猜测 E2[5] = 0 1 4 5 输出:0A 3B,对应第9组。
于是对应第三次猜测 E3_5[9] = 4 6 1 7
后续就是正常计算,但由于第二次完成之后,可能的答案范围已经缩小到70个,大大减小了循环次数。
于是代码如下(注意还需要填入数据,看代码注释)
def average_solver(answer, List=None): # 即选择产生答案排除答案平均最多的
if List is None:
L = []
get_all(ALL=L)
L0 = L[:]
else:
L = List[:]
inps = []
results = []
# 这里写下M,E2和所有的E3即可,上文有,故略去
n = 1
while 1:
if len(L) > 2:
if n == 1:
inp = '1234'
elif n == 2:
inp = E2[M.index(res)]
elif n == 3:
inp = eval("E3_{}[M.index(res)]".format(M.index(results[-2])))
else:
inp = get_best(L0, L)
inps.append(inp)
res = check(inp, answer)
L = excluse(L, inp, res)
results.append(res)
else:
inp = L[0]
inps.append(inp)
res = check(inp, answer)
results.append(res)
L = excluse(L, inp, res)
if res[0] == 4:
print(inp, res, len(L))
break
n += 1
return inps, results
写个循环,经过了长达6157.4秒的折磨,我得到了以下数据
{1: 1, 2: 4, 3: 59, 4: 574, 5: 2425, 6: 1892, 7: 85}
和百度百科数据略有不同,我的平均5.26865...次,百度百科上平均5.26805...次,可能做了微调。
倒是如果把get_best里循环L0改为循环L,就得到
{1: 1, 2: 4, 3: 59, 4: 740, 5: 2188, 6: 1877, 7: 169, 8: 2}
虽然分散了许多,出现了8次的解答,但是总共循环只需要109.11秒,而且平均步数低至8849/1680=5.2672619...,只能说出乎意料。
顺便也写了简单策略求解:(每次取可能答案里第一个)
def simple_solver(answer, List=None):
if List is None:
L = []
get_all(ALL=L)
else:
L = List[:]
inps = []
results = []
while 1:
inp = L[0]
inps.append(inp)
res = check(inp, answer)
L = excluse(L, inp, res)
results.append(res)
if res[0] == 4:
break
return inps, results
结果如下
{1: 1, 2: 13, 3: 108, 4: 596, 5: 1668, 6: 1768, 7: 752, 8: 129, 9: 5}
和百度百科数据完全一致。
三、资料与附件
最后我完善了一下代码,算是圆满完成了编程任务(第一版,不含平均指标求解)。我把编译后的exe传上来了,还加入了一些其他功能:输入"hint"查看提示、输入"ans"查看答案、输入"ans="指定答案重置、还有在每轮结束后复盘的功能。如果有需要源文件的,相信你自己可以写出来,关键部分我已经复制到文中了,其实主要是因为后面改版太多次原先的代码没了(或者你有实力直接反编译也行,反正区区pyinstaller)。
下载链接:https://ztx6.lanzouw.com/i1ymT26i83pi
快夸我这么好心没放百度网盘
最后就让我们以一个思考题收尾吧
思考题
图示信息唯一确定了一组解,写出它!
有没有人能把其他的算法完成啊……我的脑子是有点不够用了。
就这样吧,有别的想法可以私信我,24h内大概率不能回复。