海龟绘图案例分析之移动火柴变等式游戏

Python算法之旅

共 2323字,需浏览 5分钟

 ·

2022-02-15 05:50

说在前面

移动一根火柴棒使等式成立,是一种简单有趣的智力游戏,只需具备简单的算术知识,就能参与游戏,老少皆宜,深受大家的喜爱。前几天,微信群里有老师聊起了这个游戏,毛毛老师还编写了创建题库的程序,好评如潮。在毛毛老师创意的启发下,我对代码做了进一步改进,并利用海龟绘图的鼠标响应功能,制作了可以移动火柴棒的小游戏。

由于该小游戏涉及功能较多,总计400多行代码(含注释),文章篇幅较大,我把它分成了三个部分,力求解析到位。今天和大家分享第一部分——创建题库。

1.游戏规则

只允许移动一根火柴棒使等式成立。移动火柴棒的方法有三种:

一是在数字内部移动火柴棒。例如原始等式为9-5=1,则可移动数字9的一根火柴棒,使其变成6,从而获得正确等式6-5=1。

二是在数字之间移动火柴棒。例如原始等式为5-7=5,则可将数字7的一根火柴棒移到左边数字5上,使得7变成1,5变成6,从而获得正确等式6-1=5。

三是在数字和运算符之间移动火柴棒。例如原始等式为5+3=3,则可将运算符的一根火柴棒移到数字5上,使得+变成-,5变成6,从而获得正确等式6-3=3。

2.数据结构

根据上述移动火柴棒的方法,我们可以创建3个字典分别用来存储移动、增加或减少火柴棒后,各数字可能变成的新数字。

例如,我们创建字典keep_dic用来存储在数字内部移动火柴棒后,各数字可能变成的新数字。如下图所示,数字2可以变成数字3,我们记作keep_dic[2]=[3];数字3可以变成数字2或5,我们记作keep_dic[3]=[2,5];同理keep_dic[5]=[3],keep_dic[6]=[0,9]。

因为数字1不能变成任何数字,故我们记作keep_dic[1]=[]。根据上述规则,我们定义keep_dic ={1:[],2:[3],3:[2,5],4:[],5:[3],6:[0,9],7:[],8:[],9:[0,6],0:[6,9]}。

同理,我们创建字典add_dic用来存储为数字增加一根火柴棒后,各数字可能变成的新数字。如下图所示,数字1增加一根火柴后可以变成数字2,数字5增加一根火柴后可以变成数字6或9。

由此我们定义add_dic ={1:[7],2:[],3:[9],4:[],5:[6,9],6:[8],7:[],8:[],9:[8],0:[8]}。

同理,我们定义dec_dic ={1:[],2:[],3:[],4:[],5:[],6:[5],7:[1],8:[0,6,9],9:[3,5],0:[]},用来存储为数字减少一根火柴棒后,各数字可能变成的新数字。

另外,我们还创建了两个空列表ques和anss,分别用来存储有效问题和对应答案。

3.算法思想

程序采用枚举算法,采用四重循环,依次枚举运算符和3个运算数的值。

自定义函数get_question_bank(ques,anss)用来创建题库,它通过枚举不同运算符和操作数寻找所有可能的原始算术和答案。程序巧妙使用f-string来生成紧凑的字符串,并利用eval()函数计算出字符串表达式的结果,生成通用的关系表达式,以便筛选出等号不成立的原始算式。参考代码如下:

'''函数功能:通过枚举不同运算符和操作数寻找所有可能的原始算式和答案函数名:get_question_bank(ques,anss)参数表:ques -- 用来存储问题(原始算式)的列表;        sign -- 用来存储答案(正确算式)的列表,每个问题可能有多个答案。返回值:没有返回值。'''   def get_question_bank(ques,anss): #获取题库(包括问题和答案)    for sign in ['+','-']:        for a in range(10):            for b in range(10):                for c in range(10):                    if eval(f'{a}{sign}{b}') !=c: #只考虑等号不成立的原始算式                        ans = fun(a, b, c,sign) #调用寻找答案的驱动函数,返回答案                        if ans != []: #若问题有解,则存储问题和答案                           ques.append(f'{a}{sign}{b}={c}') #存储问题(原始算式)                            anss.append(ans) #存储答案(正确算式)

知识小贴士

eval()函数用于返回传入字符串的表达式的结果,还可以实现list、dict、tuple与str之间的转化。

其语法格式为:eval(expression[,globals[, locals]])

参数说明:

expression :表达式。

globals :(可选参数)变量作用域,全局命名空间,如果被提供,则必须是一个字典对象。

locals :(可选参数)变量作用域,局部命名空间,如果被提供,可以是任何映射对象。

当后两个参数都为空时,等价于eval(expression),就是计算表达式的值。例如eval('pow(2,3)')的值为8,eval('2+3<5')的值为False;当x=3时,eval(f'x+5*4')的值为23,但是eval(f'{x+5}*4')的值为32,因为把花括号内部的表达式看作一个整体。

当locals参数为空,globals参数不为空时,先查找globals参数中是否存在变量,并计算。例如eval("{'name':'Lucy','age':age}",{"age":18})的值为字典{'name': 'Lucy', 'age': 18}。

当两个参数都不为空时,先查找locals参数,再查找globals参数。

eval()与input()函数配合,可以方便地实现表达式的还原与计算,例如执行语句>>>eval(input("输入一个表达式:")),当用户输入字符串"3+2*6"时,系统输出15。

但要注意其带来的安全问题,因为被执行的字符串表达式可能是某个系统命令。例如执行语句>>>eval(input("输入一个表达式:")),当用户恶意输入字符串"__import__('os').system('dir')"时,会显示当前目录文件。


4.驱动函数

如上所述,有三种移动火柴棒的方法。对应每一种方法,我们都可以设置一个自定义函数来枚举可能的答案。为了体现模块化编程思想,我们设置一个驱动函数fun(),依次调用这三个自定义函数,把所有可能的答案统一存储到列表ans中。参考代码如下:

'''函数功能:依次调用fun_1,fun_2,fun_3,为原始算术寻找可能的答案函数名:fun(a, b, c, sign)参数表:a,b,c -- 依次表示从左到右的3个操作数(单个数字);        sign -- 运算符(加或减)。返回值:返回存储了所有答案的列表ans,列表的每个元素都是一个表示答案的字符串。'''    def fun(a, b, c, sign): #驱动函数    ans = []    fun_1(ans, a, b, c, sign) #数字内部移动火柴棒    fun_2(ans, a, b, c, sign) #数字之间移动火柴棒    fun_3(ans, a, b, c, sign) #数字和运算符之间移动火柴棒return ans

5.功能模块

接下来依次给出三种方法对应的自定义函数。

'''函数功能:利用字典keep_dic,通过在数字内部移动火柴棒来改变该数字函数名:fun_1(ans, a, b, c,sign)参数表:ans -- 列表的值为一个字符串,表示一个答案(正确算式);        a,b,c -- 依次表示从左到右的3个操作数(单个数字);        sign -- 运算符(加或减)。返回值:没有返回值。'''def fun_1(ans, a, b, c,sign): #数字内部移动火柴棒    for i in keep_dic[a]: #在数字a内部移动火柴棒        if eval(f'{i}{sign}{b}') == c:            ans.append(f'{i}{sign}{b}={c}')    for i in keep_dic[b]: #在数字b内部移动火柴棒        if eval(f'{a}{sign}{i}') == c:            ans.append(f'{a}{sign}{i}={c}')    for i in keep_dic[c]: #在数字c内部移动火柴棒        if eval(f'{a}{sign}{b}') == i:            ans.append(f'{a}{sign}{b}={i}') '''函数功能:利用字典dec_dic和add_dic,通过在数字之间移动火柴棒同时改变2个数字函数名:fun_2(ans, a, b, c,sign)参数表:ans -- 列表的值为一个字符串,表示一个答案(正确算式);        a,b,c -- 依次表示从左到右的3个操作数(单个数字);        sign -- 运算符(加或减)。返回值:没有返回值。'''               def fun_2(ans, a, b, c,sign): #数字之间移动火柴棒    for i in dec_dic[a]: #从a中拿一根火柴到b中        for j in add_dic[b]:            if eval(f'{i}{sign}{j}') == c:                ans.append(f'{i}{sign}{j}={c}')    for i in dec_dic[a]: #从a中拿一根火柴到c中        for j in add_dic[c]:            if eval(f'{i}{sign}{b}') == j:                ans.append(f'{i}{sign}{b}={j}')    for i in dec_dic[b]: #从b中拿一根火柴到a中        for j in add_dic[a]:            if eval(f'{j}{sign}{i}') == c:                ans.append(f'{j}{sign}{i}={c}')                flag = True    for i in dec_dic[b]: #从b中拿一根火柴到c中        for j in add_dic[c]:            if eval(f'{a}{sign}{i}') == j:                ans.append(f'{a}{sign}{i}={j}')    for i in dec_dic[c]: #从c中拿一根火柴到a中        for j in add_dic[a]:            if eval(f'{j}{sign}{b}') == i:                ans.append(f'{j}{sign}{b}={i}')    for i in dec_dic[c]: #从c中拿一根火柴到b中        for j in add_dic[b]:            if eval(f'{a}{sign}{j}') == i:                ans.append(f'{a}{sign}{j}={i}') '''函数功能:利用字典dec_dic或add_dic,通过在数字和运算符之间移动火柴棒同时改变1个数字和运算符函数名:fun_3(ans, a, b, c,sign)参数表:ans -- 列表的值为一个字符串,表示一个答案(正确算式);        a,b,c -- 依次表示从左到右的3个操作数(单个数字);        sign -- 运算符(加或减)。返回值:没有返回值。'''                        def fun_3(ans, a, b, c,sign): #数字和运算符之间移动火柴棒    if sign == '+': #从运算符中拿一根火柴到数字        sign, dic = '-', add_dic    else:        sign, dic = '+', dec_dic    for i in dic[a]: #在运算符与数字a间移动火柴        if eval(f'{i}{sign}{b}') == c:            ans.append(f'{i}{sign}{b}={c}')    for i in dic[b]: #在运算符与数字b间移动火柴        if eval(f'{a}{sign}{i}') == c:            ans.append(f'{a}{sign}{i}={c}')    for i in dic[c]: #在运算符与数字c间移动火柴        if eval(f'{a}{sign}{b}') == i:            ans.append(f'{a}{sign}{b}={i}')

代码说明:

函数fun_1()利用字典keep_dic,通过在数字内部移动火柴棒来改变该数字。函数依次枚举了在数字a、b、c内部移动火柴棒的情形,一旦找到满足条件的等式,就将其作为答案插入到列表ans中。

函数fun_2()利用字典dec_dic和add_dic,通过在数字之间移动火柴棒同时改变2个数字。函数依次枚举了从a拿一根火柴到b,从a拿一根火柴到c,从b拿一根火柴到a等6种情形。一旦找到满足条件的等式,就将其作为答案插入到列表ans中。

函数fun_3()利用字典dec_dic或add_dic,通过在数字和运算符之间移动火柴棒同时改变1个数字和运算符。函数先判断运算符是'+'还是'-',若为'+',则从运算符中拿一根火柴到数字,即把运算符改成'-',同时设置字典dic 的值为add_dic,表示数字要增加一根火柴棒;反之则设置成从数字中拿一根火柴到运算符。接下来依次枚举在运算符与数字a、b、c间移动火柴的情形,一旦找到满足条件的等式,就将其作为答案插入到列表ans中。

6.程序改进

前面3个自定义函数,功能都很明确,算法清晰明了,代码实现也很直接,可读性较强。但是它们都有一个缺陷,就是重复的代码太多了,显得有些拖沓和繁琐。

我们经常将重复出现的顺序结构程序转换成循环结构,那么该如何精简代码呢?

以fun_1()函数为例,它用了3个结构相同的一重循环语句,我们完全可以把它精简成一个二重循环语句。

我们先把a、b、c三个变量存储到列表role中,定义role = [a, b, c],这样就可以用for循环遍历它们了。因为a、b、c在算式中的位置不同,原fun_1()函数的3个for循环中的f字符串写法也不一样,但现在分别用role[0]、role[1]、role[2]来代替 a、b、c,就可以统一设置f字符串了。

根据这个思路,我编写了如下代码:

但实践证明这段代码是错误的。

问题出在哪里呢?

没错,问题就出在role[k] = i语句上。

当k==0时,枚举keep_dic[role[k]]就相当于枚举keep_dic[a],此时修改role[k] = i,相当于修改数字a的值,但同时也修改了列表role的元素值,导致后面枚举另两个数字时,role[0]不再等于变量a的值,而是其移动火柴棒后的新数字。

那该怎么办呢?既要使用列表遍历三个数字,枚举在它们内部移动火柴棒后的可能值,又不能修改列表元素。有两全其美的方法吗?

当然有!办法就是在内层循环中复制一份列表role的拷贝,然后在枚举过程中修改这份拷贝,而不是role本身。参考代码如下:

再来看fun_2()函数,它用6个结构相同的二重循环,依次枚举了在数字间移动火柴棒的6种情形。因为数字a、b、c在算式中的位置不同,原fun_2()函数的6个for循环中的f字符串写法也不一样,所以每一个二重循环的语句都有微妙的差别,合并代码的难度很大。

但它们的结构如此一致,肯定能够用一个循环语句来代替重复的顺序结构!

必须要有坚定的信念,才能找到坚持的动力!

在精简fun_1()函数的实践过程中,我们采用了2个重要的方法:创建列表role存储变量a、b、c,和在循环体内创建列表role的拷贝op。

我们可以把旧有的经验迁移到解决新问题中去。

但这里有一个新的问题:从一个数字中拿走的火柴棒可能放到另外任意一个数字中,假设被拿走火柴棒的数字在列表role中的下标为i,则另外两个数字的下标分别是多少呢?

若i=0,则另外两个可以用i+1和i+2来表示;若i=1,则另外两个可以用i-1和i+1来表示;若i=2,则另外两个可以用i-2和i-1来表示。也就是没有统一的表达式,不方便用变量i来表示另外两个数字的下标。

真的没有办法吗?

办法肯定是有的!只是还没有找到。

必须要有坚定的信念,才能找到坚持的动力!

a,b,c变量的地位是相等的,不要把它们看作站成一排,而是把它们看作围成一圈——这不就是约瑟夫环吗!循环报数?用模运算啊!

思路出来了!

我们可以用(i+1)%3和(i+2)%3分别表示另外两个元素的下标,则当i=0时,另外两个下标分别为1和2;当i=1时,另外两个下标分别为2和0;当i=2时,另外两个下标分别为0和1。

简直是完美!

这样我们就可以构造一个for循环,用变量j来遍历这两个下标,实现枚举从数字role[i]中拿走一根火柴棒,增加到数字role[j]中的情形。加上遍历列表role的最外层循环,总共4重循环。参考代码如下:

好了,解说就进行到这里,剩下fun_3()函数的精简任务就作为课后作业交给大家完成吧。

课后练习

1.  参考fun_1()函数的改进方法,将fun_2()函数精简为二重循环结构。

2.  有一种特殊的等式,可以移动其中一根火柴棒,使其变成另一个正确的等式。如下图所示:

等式3+6=9,通过从数字6移动一根火柴棒到数字9中,形成新的等式3+5=8;

等式0+4=4,通过从加号中移动一根火柴棒到数字0中,形成新的等式8-4=4。

你还可以找出类似的等式吗?如何用程序实现呢?


需要本文PPT、源代码和课后练习答案的,可以加入“Python算法之旅”知识星球参与讨论和下载文件,Python算法之旅”知识星球汇集了数量众多的同好,更多有趣的话题在这里讨论,更多有用的资料在这里分享。

我们专注Python算法,感兴趣就一起来!

相关优秀文章:

阅读代码和写更好的代码

最有效的学习方式

Python算法之旅文章分类

浏览 67
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报