1A2B猜数字游戏(Bulls and Cows)的Python实现和求解

咳咳,提前说一句,本文章也发在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 28 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),也就是分猜出来需要的步数输出提示语。

最后测试一下,非常正常的运行了:

某次运气爆炸的游戏记录

二、策略与具体求解

接下来是本文章的重头戏了!

可以看出,所有解法的一般过程如下(不同策略的差异在于指标不一样

  1. 准备一个列表,包含所有可能的数字(共N=10\cdot9\cdot8\cdot7=5040项,每项四个字节,需要的内存不大,处理起来也不算太困难),记为L_0;复制一份作为可能是答案的范围,记为L_1
  2. 首先输入1234(输入什么都一样),得到第一次结果。
  3. 通过得到的结果,遍历列表L_1,看看哪项不可能是答案(假设它是答案第一次就不是这个结果),并从L_1中排除这些项目。
  4. 通过L_1的剩余项目,用某一个指标对所有数字(即L_0)进行打分,选择其中打分最高的作为输入,得到对应的结果
  5. 反复执行③-④,直到可能是答案的范围缩减到只有一项,这项必然是答案

可见对于这个游戏,它的实质是通过每一步来获取信息,通过信息修正我们的猜测,缩小可能答案的范围,最终达到答案的过程。可知最优策略就是在整个过程最大化、最高效地获取信息。然而达到这个目的十分困难,我们可以先退而求其次,计算当前这步如何才能获得最多的信息(也就是考虑局部最优)。因为信息的获取意味着不确定性的减少,也就是能排除掉数字的增加,于是考虑这步所有可能的的输入-输出组合,计算哪个输出能排除掉的数字平均来说数目有多少以作为④中的打分标准。

我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=该结果出现后剩余可能答案 / 原先总的答案数量

故而平均剩余应该是Average=\sum N_{exclused}\cdot\! P=\sum N_{exclused}\cdot\! \frac{ N_{exclused}}{N_{before}}

那么每一步应该保证找到能排除最多的,也就是平均剩余量最小的,代码如下:

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就说明这种情况会导致N_{exclused}=0,即不存在这种可能性。

(以下是组的对应表)

#     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内大概率不能回复。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值