项目Github地址:https://github.com/wtt1002/OperationTest.git
PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 30 | 30 |
· Estimate | · 估计这个任务需要多少时间 | 600 | 700 |
Development | 开发 | 560 | 700 |
· Analysis | · 需求分析 (包括学习新技术) | 40 | 30 |
· Design Spec | · 生成设计文档 | 40 | 30 |
· Design Review | · 设计复审 (和同事审核设计文档) | 10 | 20 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 20 | 20 |
· Design | · 具体设计 | 60 | 80 |
· Coding | · 具体编码 | 300 | 420 |
· Code Review | · 代码复审 | 60 | 40 |
· Test | · 测试(自我测试,修改代码,提交修改) | 30 | 60 |
Reporting | 报告 | 100 | 120 |
· Test Report | · 测试报告 | 40 | 40 |
· Size Measurement | · 计算工作量 | 20 | 20 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 40 | 60 |
合计 | 660 | 820 |
项目需求
完成一个能自动生成小学四则运算题目的命令行 “软件”,满足以下需求:
- 参与运算的操作数(operands)除了100以内的整数以外,还要支持真分数的四则运算,例如:1/6 + 1/8 = 7/24。操作数必须随机生成。(已完成)
- 运算符(operators)为 +, −, ×, ÷ (如运算符个数固定,则不得小于3)运算符的种类和顺序必须随机生成。(已完成)
- 要求能处理用户的输入,并判断对错,打分统计正确率。(已完成)
- 使用 -n 参数控制生成题目的个数,例如执行下面命令将生成5个题目(已完成)
(以C/C++/C#为例) calgen.exe -n 5
(以python为例) python3 calgen.py -n 5
附加功能(算附加分)
- 支持带括号的多元复合运算(正在调整)
- 运算符个数随机生成(考虑小学生运算复杂度,范围在1~10)(已完成)
要求与说明
- 【编程语言】不限
- 【项目设计】分析并理解题目要求,独立完成整个项目,并将最新项目发布在Github上。
- 【项目测试】使用单元测试对项目进行测试,并使用插件查看测试分支覆盖率等指标。
- 【源代码管理】在项目实践过程中需要使用Github管理源代码,代码有进展即签入Github。签入记录不合理的项目会被助教抽查询问项目细节。
- 【博客发布】按照要求发布博客,利用在构建之法中学习到的相关内容,结合个人项目的实践经历,撰写解决项目的心路历程与收获。博客与Github项目明显不符的作业将取消作业成绩。
需求分析
- 所有参与运算的运算数取值范围整数(0~99),真分数要保证其为最简真分数,注意分母为0的情况,同时注意保证除数不为0。
- 运算结果可能出现负数。
- 运算符不少于3个,且随机,可暂定固定为3个,在四则运算中随机取3个。
- 有输入与输出,可统计正确率。
- 可控制生成题目的数目,数目由用户输入决定。
解题思路
刚开始看到四则运算,感觉不难,后来好好分析了一下,其实不简单,有很多问题。需要考虑。那就先把问题一步一步化简。
首先,把运算符的个数固定下来,就固定为3个,那么相应的操作数为4个。其次找出在这个项目里有几个重要的方法,一是运算式的生成,二是运算式结果的计算,三是运算结果比较,四是分数统计。
后期功能实现了扩展,可以随机运算符的数量,控制四则运算式的长度随机
需要注意的是:
(1)真分数的形成
(2)随机化生成操作数
(3)随机化生成操作码
(4)注意分母为0的情况
(5)注意正负号在分子还是分母上(后期添加的注意事项)
部分思路参考自:程序生成30道四则运算(包括整数和真分数) - 代码小逸 - 博客园 http://www.cnblogs.com/ly199553/p/5247658.html
设计实现
最开始的思路是把分数的“/”也做除法运算,即基本运算为+ - * /,设计到一半,就开始写代码了,结果遇到中间计算结果如何处理的瓶颈,计算结果如果是分数形式无法保证。我不得不将前面的思路前部推翻重来!真的是教训啊!
再次思考之后的思路是:
- 创建OperationNum类,把整数、分数、运算符都做包装在一个OperationNum对象里,这为后面进行堆栈操作提供了便利(中缀式变后缀式、计算结果)。OperationNum类中,IsFuHao用于判断当前对象是符号还是数字,对于整数分母置为1;对于分数,分母一定不为1。为了保证数据操作的一致性,做所有的计算操作都保证最终结果的分母为正数。此外此类还实现随机数、随机分母、分子的生成。
- 创建一个OperationConstruction类,该类的主要任务是实现运算四则运算式的生成。其结果为得到一个OperatonNum对象数组,初始化得到的OperationNums[]的大小。
例如数组大小为7的时候,数组结构如下:
OperationNums[0] | OperationNums[1] | OperationNums[2] | OperationNums[3] | OperationNums[4] | OperationNums[5] | OperationNums[6] |
---|---|---|---|---|---|---|
操作数1 | 运算符1 | 操作数2 | 运算符2 | 操作数3 | 运算符3 | 操作数4 |
- 创建ResultDeal类,该类包含了一系列方法,最重要的方法有 InToPost(得到后缀式),OperationCalculate(得到计算结果)。后面在核心代码展示阶段还会详细说明。
- 创建UserIO类,该类主要是实现控制台取得用户输入的题目数量和用户的计算结果。才外,该类实现结果的比对与分数的统计。
系统流程如下:
项目中的类
输入输出控制类(UserIO):获取用户输入的运算式个数,和运算结果,并对非法输入进行检测
- 成员方法
方法名 | 参数 | 返回值 | 功能 |
---|---|---|---|
OutPutOp | OperationNum[] | void | 展现更人性化的输出效果 |
OutCome_Compare | OperationNum,String | boolean | 判断用户的结果是否正确 |
操作数类(OperationNum):生成随机数,同时对分数进行相应的处理
- 成员变量
变量名 | 类型 | 备注 |
---|---|---|
IsFuHao | boolean | 无 |
up | int | 在分数情况下,小于分母 |
down | int | 在整数情况下,值为1 |
FuHao | char | +-*÷ |
- 成员方法
方法名 | 参数 | 返回值 | 功能 |
---|---|---|---|
GetNum | void | int | 获取100以内随机数 |
GetRandom_down | Random_up | int | 获取随机分子 |
GetRandom_up | void | int | 获取随机分母 |
运算式生成类(OperationConstruction):控制生成合法的表达式
- 成员变量
变量名 | 类型 | 备注 |
---|---|---|
operationNums | OperationNum[] | 无 |
operator | char[] | + - * ÷ |
- 成员方法
方法名 | 参数 | 返回值 | 功能 |
---|---|---|---|
CreateOperation | void | void | 填充operationNums[] |
OperationConstruction | void | void | 执行CreateOperation |
GetOperation | void | OperationNum[] | 获取生成的运算时operationNums[] |
结果处理类(ResultDeal):对用户输入结果进行比较和统计
- 常量
常量 | 类型 | 备注 |
---|---|---|
priorityArray | int[][] | 运算符优先级矩阵 |
- 数据结构
数据结构 | 类型 | 备注 |
---|---|---|
stack_post | OperationNum | 用于获得后缀式 |
stack_calculate | OperationNum | 用于获得计算结果 |
operationNums_post | OperationNum | 用于存放后缀式 |
- 成员方法
方法名 | 参数 | 返回值 | 功能 |
---|---|---|---|
InToPost | OperationNum[] | OperationNum[] | 中缀式变后缀式 |
OperationCalculate | OperationNum[] | OperationNum | 由后缀式得到最终结果 |
Calculate | OperationNum,OperationNum,OperationNum | OperationNum | 获取运算符符号,执行运算 |
add | OperationNum,OperationNum | OperationNum | 加法 |
subtract | OperationNum,OperationNum | OperationNum | 减法 |
multiply | OperationNum,OperationNum | OperationNum | 乘法 |
divide | OperationNum,OperationNum | OperationNum | 除法 |
GCD | int,int | int | 获得两数的做大公约数 |
checkLocation | char | int | 获取运算符在priorityArry的坐标 |
核心代码
- 四则运算式的构造
本工程里所有的操作数与运算符都被封装为OperationNum类型。
public void CreateOperation()
{
Random random=new Random();
{
int ZhengOrFen;//随机决定是整数还是分数
int FuHao;//随机化符号
for(int i=0;i<Length_Operation;i=i+2)//取分数和符号三次
{
ZhengOrFen=Math.abs(random.nextInt()%2);
if(ZhengOrFen==1)
{
OperationNum FenNum=new OperationNum();
FenNum.down=FenNum.GetRandNum_down();
FenNum.up=FenNum.GetRandNum_up(FenNum.down);
int gcd=GCD(FenNum.up, FenNum.down);
FenNum.down/=gcd;
FenNum.up/=gcd;
operationNums[i]=FenNum;
}
else//0取整数
{
OperationNum ZhengNum=new OperationNum();
ZhengNum.down=1;
ZhengNum.up=ZhengNum.GetNum();
operationNums[i]=ZhengNum;
}
if (i+1<Length_Operation) {
FuHao=Math.abs(random.nextInt()%4);
OperationNum FuNum=new OperationNum();
FuNum.operator=operator[FuHao];
FuNum.IsFuHao=true;
operationNums[i+1]=FuNum;
}
}
}
}
通过该方法,可以随机构造运算式,并且操作数与运算符是相同类型,为后面中缀式转后缀式及运算式结果的计算做好铺垫。CreateOperation()操作完成后,operationNums[]被填充起来。
- 运算结果的计算
public OperationNum OperationCalculate(OperationNum opNums[]) {
//先变为后缀式
InToPost(opNums);
OperationNum NumOne=new OperationNum();
OperationNum NumTwo=new OperationNum();
//清空堆栈
stack_calculate.clear();
for (int i = 0; i < operationNums_post.length; i++) {
if (operationNums_post[i].IsFuHao) {//如果是符号出栈运算
NumTwo=stack_calculate.pop();
NumOne=stack_calculate.pop();
OperationNum NewTemp=new OperationNum();
NewTemp=Calculate(NumOne,NumTwo,operationNums_post[i] );
//检查每次压栈的数字是否合法
//if(NewTemp.down=0)
stack_calculate.push(NewTemp);
}
else {//如果是数字,入栈
//System.out.println("result70,准备入栈");
stack_calculate.push(operationNums_post[i]);
}
}
OperationNum outComeNum=stack_calculate.pop();
return outComeNum;
}
该函数完成运算式结果的计算,返回计算结果,计算结果也为OperationNum类型。函数最开始调用InToPost(OperationNum opNums[]),InToPost函数的主要功能是实现中缀式转后缀式,别把结果存入一个OperationNum对象数组operationNums_post[ ]里面。得到后缀式后,再利用栈,再对后缀式进行计算。
- 对于除法操作的特殊处理
由于除法的除数不能为零,并且为了处理的方便,我们把所有的负号都放在了分子上,可是直接进行除法操作,很可能把负号带到分母里面。故需要特殊处理一下。
public OperationNum divide(OperationNum NumOne,OperationNum NumTwo) {
//如果分母为0,IsLegal置false,操作中止
if (NumTwo.up==0) {
IsLegal=false;
return null;
}
OperationNum tmpNum=new OperationNum();
tmpNum.up=NumOne.up*NumTwo.down;
tmpNum.down=NumOne.down*NumTwo.up;
if (tmpNum.down<0) {//若分母为负数
tmpNum.up=tmpNum.up*(-1);
tmpNum.down=tmpNum.down*(-1);
}
int gcd=GCD(tmpNum.up, tmpNum.down);
tmpNum.up/=gcd;
tmpNum.down/=gcd;
return tmpNum;
}
- 用户输入计算结果的计算
public boolean OutCome_Compare(OperationNum outCome,String outString) {
String outComeString=new String();
//如果计算结果为整数
if (outCome.down==1) {
outComeString=""+outCome.up;
}
else {//计算结果为分数
outComeString=""+outCome.up;
outComeString+="/";
outComeString+=outCome.down;
}
if (outComeString.equals(outString)) {
return true;
}
return false;
}
用户输入存为String类型,计算结果也转换为String类型,方便比较。
测试运行
软件运行截图
较简单的模式下,随机数的取值在0~9之间。
进击模式下,整数的取值范围为099,分子分母取值范围为19
在困难模式下,整数取值范围为099,分子分母取值范围199
测试中出现的问题
因为运算符个数随机,出现了运算符为一个的情况。
对于这个问题在随机运算符个数的时候,做了微调整。
Random random=new Random();
//随机化符号的个数
int randomLen_FuHao=Math.abs(random.nextInt()%10);//可能随机到0;
//得到随机后的运算式长度
int Length_Operation=randomLen_FuHao*2+1;
调整之后:
Random random=new Random();
//随机化符号的个数
int randomLen_FuHao=Math.abs(random.nextInt()%9)+1;//随机范围为1~9
//得到随机后的运算式长度
int Length_Operation=randomLen_FuHao*2+1;
单元测试结果
测试运算式的构造
@Test
public void testConstruction()
{
OperationConstruction operationConstruction=new OperationConstruction();
OperationNum[] opNums=operationConstruction.GetOperation();
int length=opNums.length;
Assert.assertEquals(1, length%2);
System.out.println("运算式长度:"+length );
}
结果处理 中缀式转后缀式 测试
@Test
public void testIntoPost() {
ResultDeal resultDeal=new ResultDeal();
// 1/2+4÷2/3*8
OperationNum[] testNums=new OperationNum[7];
testNums[0]=new OperationNum(false,1,2,' ');
testNums[1]=new OperationNum(true,1,1,'+');
testNums[2]=new OperationNum(false,4,1,' ');
testNums[3]=new OperationNum(true,1,1,'÷');
testNums[4]=new OperationNum(false,2,3,' ');
testNums[5]=new OperationNum(true,1,1,'*');
testNums[6]=new OperationNum(false,8,1,' ');
OperationNum[] testNums_post=resultDeal.InToPost(testNums);
for (int i = 0; i < testNums_post.length; i++) {
if (testNums_post[i].Get_IsFuHao()) {
System.out.print(testNums_post[i].Get_FuHao()+" ");
}
else {
if (testNums_post[i].Get_down()>1) {
System.out.print(testNums_post[i].Get_up());
System.out.print("/");
System.out.print(testNums_post[i].Get_down()+" ");
}
else {
System.out.print(testNums_post[i].Get_up()+" ");
}
}
}
System.out.println();
}
通过测试,暂时未发现不正确的输出。
项目总结
这次项目耗时较长,没想到会消耗这么长的时间,只要原因是在开始的设计思路出现了问题。最开始想直接用字符串存储我随机生成的运算表达式,用这种结构,前面还勉勉强强能写下去。直到写到运算处理,如果把“/”也看做除法,那我运算之后如何存为分数形式呢?想到这一块,才明白前面的设计都错了,贪图节省时间,实际却浪费了很多时间。后面,我就转换思路,我开始思考可不可以用一个类把整数、分数、操作符都包起来,后来这个方法行通了,并且扩展性还挺好,但是在存储消耗上会大一些。
完成项目的基本功能之后,我又实现了一个附加功能,可以随机化运算符的个数。在设计分数的运算时,我发现分子和分母取值很大没有意义,有悖于给小学生出题的设计初衷,于是将随机化生成的分子分母都小于10。为了实现随机化设计的分数都是真分数,我首先随机化生成分母,然后再把分母的随机化取值作为分子随机化的范围,这样就可以保证生成的分数一定是真分数了。在分子分母随机化过程中要剔除等于0的情况。如果分母为0,没有意义,去掉;如果分子为0,分母不为0,整个分数等于0,实际就是整数了。而在数据处理的规则里,对于整数,分母保证其为1,即:如果Get_up()方法得到的结果为1,则这个数为整数。
此外,这次项目我练习使用了Junit,对单元测试有了一个初步的了解,觉得它特别的方便。我之前调代码都是通过打印一些状态信息发现问题或者是断点调试,用了junit之后,发现之前的方法其实是很繁琐的。我之前没有接触过软件工程开发,对它的了解是从0开始的,还有许多软件性能测试的软件与方法,我现在还不太懂,但是我会一点点不断加入到我的软件开发过程中,争取把自己的项目做的更好。
2017年9月27日 更新:
今天和同学讨论我们所写程序的执行效率问题,于是我回实验室对我的代码做了一个简单的测试:
单纯出题目10000题耗时约0.554s,出100000题耗时约3.917秒。
单纯出10000题:
单纯出100000题:
如果对所出题进行判断去除掉会使分母为零的情况,并计算出运算结果,速度会稍微放慢一点,出10000题耗时约0.574s;出100000题约4.474s。整体效率较高。
出10000道题,并验证:
出100000道题,并验证: