软件工程学习之小学四则混合运算出题软件 Version 1.1 设计思路及感想
继上次采用形式文法来生成混合运算的算式,由于算法中没有引入控制参数而导致容易产生形式累赘(多余的括号等)的算式。本次更新决定采用一种更为简单有效的生成方式,由给出的一个随机的最终答案S,通过给定的一个基本运算(加减乘除)将数字分解为两个数a,b,使得这两个数的运算结果为之前的数S,那么a,b分别可按同样的规则进行拆分,如此反复多次便可得到一个混合运算算式。这个过程实际上也是二叉树的生成过程,也是我们相当熟悉的算法了。当然,为了生成正确的算式还是需要解决一基本些问题的。
问题1:使数S按某一运算拆解,那么应该如何去拆解?
分析:对数字实现按某运算实现拆解,无疑我们的讨论范围只有加减乘除了,那么只需对四种情况分别设计相应的算法最终打包即可。注:本次算法仅实现正整数运算拆解。
⑴加法:S=a+b,那么令 a=rand()%S,b=S-a 即可;特殊地,S=0 时 a=b=0 ;
⑵减法:S=a-b,那么令 a=S+rand()%N (N>0),b=a-s 即可;特殊地,S=0 时 a=b=C (C是大于零的常数);
⑶除法:S=a/b,那么令 a=S*(1+rand()%N),b=a/s 即可;特殊地,S=0 时 a=0,b=C1(C1是大于零的常数);
⑷乘法:既然把乘法放到最后,那么可想而知实现起来是有点麻烦了。
S=a*b,那么意味着 a,b 都是S的因子,那么如何随机地获得一组S的因子呢?
首先,最简单的方法是我们可以用枚举法来获得S的所有因子:
for(i=i;i<S;i++) //枚举,这个算法会得到重复的因子组合
if(!(S%i)) //能否除尽
cout<<i<<endl; //打印因子
假设已经获得S的n个因子,那么我们只需要等可能地从这n个因子中随机选择一个a,那么 b=S/a 即可。
鉴于本人懒╮(╯∇╰)╭,我采用如下算法:
for(i=1;i<S;i++)
if(!(S%i))
if(rand()%2) //这样做的坏处是对于S的所有因子而言,a取到1的概率最大,嘛~~╮(╯_╰)╭,下个版本再改进吧。
break;
a=i,b=S/i;
特殊地,S=0 时,a or b=0即可。
分装后的函数:
/* Random split */
void OP_Decompose(Operator OP,int &a,int &b,int num) //分别用 0,1,2,3 来代表加、减、乘、除 typedef unsigned char Operator
{
switch(OP)
{
case 0:// +
{
if(num)
{
a=rand()%(num+1);
b=num-a;
}
else a=b=0;
}break;
case 1:// -
{
if(num)
{
a=num+rand()%100;
b=a-num;
}
else a=b=rand()%100;
}break;
case 2:// *
{
if(num)
{
int i;
for(i=1;i<num;i++)
if(!(num%i))
if(rand()%2)
break;
a=i,b=num/i;
}
else
{
if(rand()%2)
a=rand()%100,b=0;
else
b=rand()%100,a=0;
}
}break;
case 3:// /
{
if(num)
{
a=num*(1+rand()%10+rand()%10);
b=a/num;
}
else
a=0,b=rand()%100+1;
}
}
}
通过上述讨论,拆解问题就此告一段落了,其实也是整个程序中的核心问题之一吧(,,#゚Д゚)。
问题2:之前说过了是以二叉树生成的方式来产生算式,那么如果父节点的运算符优先级高于子节点的运算符,但实际上是先计算子节点的,那么就需要括号介入了。
分析:如果在生成二叉树的过程中,以先建立好父节点的所有数据信息再分别递归构造子节点的方式生成,若父节点对子节点有某些要求,那么只需要通过参数传递来约束子节点的建立。即,当父节点在构造子节点时将自己的运算符告诉子节点,然后子节点通过对比与父节点的运算优先级来决策是否添加括号。而在对比优先级时,我们只需要考虑父节点的优先级是否高于子节点,无需关心孰高孰低。在上一个问题中,我们定义了加减乘除的代号分别为0,1,2,3 ,观察发现0,1的二进制数高位为0,而2,3的二进制数高位为1,那么只需要对高位进行对比即可。便有如下宏定义:
#define OA 0 //加法
#define OS 1 //减法
#define OM 2 //乘法
#define OD 3 //除法
#define JUD_Priority(OP1,OP2) (((Operator)(OP1)>>1<(Operator)(OP2)>>1)?true:false) //当且仅当OP2优先级高于OP1时返回1
到这里,需要面临的也就是递归下降生成的最后问题了。( ̄- ̄)
问题3:形式控制之算符偏好与数量(递归深度)。
所谓偏好,就是指在生成的式子里某种运算出现的频率高低。而数量就是一道算式里出现的算符个数。
分析:首先来考虑偏好吧,回顾我们最初的需求是随机地产生混合运算题目,那么这个随机既代表了算数的随机也代表了算符的随机。基于上述讨论中的按算符拆分原则,每次拆解一个数的时候我们需要随机的选择一个算符(也就是本节点的算符),即每个节点都会为自己随机产生一个算符,最终的题目在逻辑上是这些节点连成的二叉树。如果在每次选择算符的时候引入概率,那么当生成很多道题目的时候在每种运算出现的频率上就可以体现出偏好了。以最简单的情况为例,如果算符选择的概率是一定的,我们之需要分装一个函数来实现选择就可以了。当然,为了让这个函数能参与更多的子程序编写,这里采用不定参数实现:
#include <cstdarg>
/* Random selection */
int PR_Select(unsigned int n=1,...) //实现n个数分别按概率P1,P2...Pn的随机选择
{
if(!n) return 0;
va_list ap;
va_start(ap,n);
float p=0.0f,*pn=new float[n];
if(!pn) return -1;
int i,j=0;
for(i=0;i<n;i++)
p+=pn[i]=(float)va_arg(ap,double);
if(p-1>=-1e-5&&p-1<=1e-5)
{
float pbt=((float)rand())/((float)RAND_MAX); //#define RAND_MAX 0x7fff 包含在stdlib.h
float ff;
for(i=0,ff=0.0f;i<n;ff+=pn[i],i++)
{
if(pbt>=ff&&pbt<ff+pn[i])
{
j=i+1;
break;
}
}
}
delete [] pn;
return j;
}
/* Random operator */
inline int OP_Select(float p1,float p2,float p3,float p4) //随机选择一个算符,概率分别为p1,p2,p3,p4
{
return PR_Select(4,p1,p2,p3,p4)-1;
}
最后,为什么需要考虑算符数量?先来考虑函数的递归;终止条件是递归函数必须具备的,在递归生成算式的时候,递归何时终止将直接影响所生成算式的长度。在逻辑上看,算符的数量决定了二叉树的非叶子节点个数,同时它又影响着叶子节点的个数。直观地,随着非叶子节点的增加,叶子节点的数量与非叶子节点的数量呈正相关,可见控制非叶子节点的数量即控制了总节点的数量,即算式的长度。假设用一个参数OP_num来表示算式中出现的算符个数,那么如何引入该参数呢?
分析:
设函数 void func(...) 为递归函数,它将实现生成一个算符数量为OP_num并且计算结果为num的算式。那么我们就确定了它需要OP_num和num作为参数。同时,因为是递归调用的,回顾问题2可知还需要一个供括号决策的Operator型参数Parent。
那么函数声明为:void func(Operator Parent,int num,OP_num);
显然参数Parent和num对递归深度无影响。
定义:OP_num=0 时,函数将直接打印num并返回;即表示不需要对num进行任何拆分。
当 OP_num>0 时,首先对num进行了一次拆分获得a,b,OP_num减一;当OP_num不为0就意味着a,b可以用OP_num(已经减一)个算符去拆分,此时我们可以将OP_num以加法拆分为两个数n1,n2(问题1所分装的函数中已表明对0的加法拆分依然是两个0),那么对a,b分别递归调用就可以了。最终分装函数如下:
/* probability of operators */
#define PO_A 0.35f
#define PO_S 0.35f
#define PO_M 0.2f
#define PO_D 0.1f
#include <iostream>
/* Output operator */
void OP_print(Operator OP)
{
switch(OP)
{
case 0: cout<<"+";break;
case 1: cout<<"-";break;
case 2: cout<<"×";break;
case 3: cout<<"÷";break;
}
}
/* Produce formula */
void FormulaGenerator(Operator Parent,int num,int OP_num)
{
if(!OP_num)
{
cout<<num;
return;
}
Operator here=OP_Select(PO_A,PO_S,PO_M,PO_D);
int a,b,OP_n1,OP_n2;
OP_num--;
OP_Decompose(here,a,b,num);
OP_Decompose(0,OP_n1,OP_n2,OP_num);
if(JUD_Priority(here,Parent)) //括号决策
{
cout<<"(";
FormulaGenerator(here,a,OP_n1);
OP_print(here);
FormulaGenerator(here,b,OP_n2);
cout<<")";
}
else
{
FormulaGenerator(here,a,OP_n1);
OP_print(here);
FormulaGenerator(here,b,OP_n2);
}
}
至此,整个生产算符就实现完毕了,如下是效果图:
可见这次程序相比上次对算式形式的控制更加精确,目前就这样吧,更多功能及优化将在后续版本实现。2016/3/14