海龟绘图案例分析之移动火柴变等式游戏
说在前面
移动一根火柴棒使等式成立,是一种简单有趣的智力游戏,只需具备简单的算术知识,就能参与游戏,老少皆宜,深受大家的喜爱。前几天,微信群里有老师聊起了这个游戏,毛毛老师还编写了创建题库的程序,好评如潮。在毛毛老师创意的启发下,我对代码做了进一步改进,并利用海龟绘图的鼠标响应功能,制作了可以移动火柴棒的小游戏。
由于该小游戏涉及功能较多,总计400多行代码(含注释),文章篇幅较大,我把它分成了三个部分,力求解析到位。今天和大家分享第一部分——创建题库。
只允许移动一根火柴棒使等式成立。移动火柴棒的方法有三种:
一是在数字内部移动火柴棒。例如原始等式为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。
根据上述移动火柴棒的方法,我们可以创建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个运算数的值。
自定义函数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算法,感兴趣就一起来!
相关优秀文章: