四则运算题目生成程序
项目已提交到Coding.net:arith-exercise
需求分析
- 使用 -n 参数控制生成题目的个数
- 使用 -r 参数控制题目中数值(自然数、真分数和真分数分母)的范围,该参数可以设置为1或其他自然数。该参数必须给定,否则程序报错并给出帮助信息。
- 生成的题目中如果存在形如e1 ÷ e2的子表达式,那么其结果应是真分数。
- 每道题目中出现的运算符个数不超过3个。
- 其中真分数在输入输出时,真分数五分之三表示为3/5,真分数二又八分之三表示为2’3/8。
- 程序一次运行生成的题目不能重复,即任何两道题目不能通过有限次交换+和×左右的算术表达式变换为同一道题目。
- 程序应能支持一万道题目的生成 [spoiler]然而并没有规定时间哈哈哈[/spoiler]。
- 程序支持对给定的题目文件和答案文件,判定答案中的对错并进行数量统计,并会输出所有题目中重复的题目。
功能设计
- 根据命令行参数种类的不同来分别进入两种模式:算式生成和算式检查
- 设计一个数字类,用来封装分数和四则运算操作
- 题目没有给出算式生成模式时输出的答案文件名的命令行参数,所以默认为“Answer.txt”
设计实现
采用Java开发
项目结构
- arith
Arith
封装了数学方法,比如算式校验和算式计算Checker
检查测验文件,进行算式查重,并将答案与答案文件里的比对,输出结果到成绩文件Creator
生成算式
- model
Expression
表达式Number
数字,封装了四则运算和约分等操作
Config
各种配置,比如-n、-r等Main
程序入口,读取参数并进入相应模式
Model类
这里列出两个model类——数字和表达式,因为篇幅原因省略掉了很多方法和方法的实现,具体可以到Coding.net里查看
public class Number {
// 把每个数字都视为分数(可能是假分数), 如果是整数的话就把分母设为1
private int mNumerator;
private int mDenominator;
// 通过整数、分子和分母、格式化的数字字符串三种方式来构造
public Number(int value) { }
public Number(int numerator, int denominator) { }
public Number(String number) { }
// 根据运算符来执行对应四则运算
public Number operate(String operator, Number number) { }
// 封装的四则运算操作
public Number add(Number number) { }
public Number sub(Number number) { }
public Number mul(Number number) { }
public Number div(Number number) { }
// 约分分数,用到了网上找到的最大公约数算法
// 每次运算后都会调用一次该函数
public void reduce() {
if (mNumerator == 0) // 不需要约分
return;
// 计算最大公约数
// Ref: http://blog.csdn.net/iwm_next/article/details/7450424
int a = Math.abs(mNumerator);
int b = mDenominator;
while (a != b)
if (a > b)
a = a - b;
else
b = b - a;
mNumerator /= a;
mDenominator /= a;
}
}
// 表达式是一个字符串数组,其中每个元素都是格式化的数字或者运算符,比如
// (1+2/3)*4'5/6 => ["(", "1", "+", "2/3", ")", "*", "4'5/6"]
public class Expression extends ArrayList<String> {
// 解析一个字符串,转化为表达式类型
public static Expression fromString(String src) { }
}
代码说明
对于表达式的计算,参考了这篇博客的算法,但是他的算法有个地方有点问题:
else { // 优先级小于栈顶运算符,则弹出
tmp = stack.pop();
// 这里不应该把element加入suffix里,应该把它压入栈中
// suffix.append(tmp).append(" ").append(element).append(" ");
suffix.append(tmp).append(" ");
stack.push(element);
}
生成算式的方法
public static void create(Config config, boolean putAnswer) {
if (!isConfigValid(config))
return;
int number = config.number;
int range = config.range;
List<Expression> expressions = new ArrayList<>(number);
List<Number> answers = new ArrayList<>(number);
for (int i = 0; i < number; i++) {
try {
Expression exp = createExpression(range);
Number ans = Arith.evaluate(exp);
// 检查是否存在重复的算式,先检查答案是否重复再检查算式本身
if (Checker.hasSameAnswer(answers, ans) && Checker.findDuplicate(expressions, exp) != -1)
throw new ArithmeticException("Expression duplicated: " + exp);
expressions.add(exp);
answers.add(ans);
} catch (ArithmeticException e) {
// 如果生成失败了就回退,再试一次
i--;
}
}
// 保存结果到文件
output(config.output, expressions, answers, putAnswer);
}
生成随机数字的方法
private static Number randomNumber(int numberMax) {
if (Math.random() < PR_INTEGER) // PR_INTEGER=0.8,是生成一个整数的概率
// 生成一个整数
return new Number((int) (Math.random() * numberMax - 1) + 1);
else
// 生成一个分数
return new Number((int) (Math.random() * numberMax - 1) + 1, (int) (Math.random() * numberMax - 1) + 1);
}
在原算式的基础上添加一个运算的方法,随机选取一个数字,比如将1+2*3里的2替换为2-4,或者带括号的(2-4)
private static void addOperation(Expression exp, int numberMax) {
int size = exp.size();
int position = 0;
int loops = 0;
// 随机选择一个数字
while (true) {
if (Arith.isNum(exp.get(position).charAt(0)) && Math.random() > 0.5)
break;
// 如果搜索结束了还没有选中数字,就回退到起点然后重新搜索
if (++position == size)
position = 0;
if (loops++ == 50) {
System.out.println("Oops, something went wrong...?");
return;
}
}
boolean addBrackets = Math.random() > PR_BRACKET; // PR_BRACKET=0.5,是插入一个括号的概率
if (addBrackets)
exp.add(position++, "(");
exp.add(++position, randomOperator());
exp.add(++position, randomNumber(numberMax).toString());
if (addBrackets)
exp.add(++position, ")");
}
从文件读取并检查算式结果的算法
// 示例:
// Exercise.txt > 1. 1+2*3=7
// Answer.txt > 1. 7
while ((expLine = exerciseReader.readLine()) != null) {
separator = expLine.indexOf('=');
exp = Expression.fromString(expLine.substring(expLine.indexOf('.') + 2, separator));
ansLine = answerReader.readLine();
rightAnsStr = ansLine.substring(ansLine.indexOf('.') + 2);
// 检查答案
if (separator + 1 < expLine.length() // 填有答案
&& rightAnsStr.equals(expLine.substring(separator + 1))) // 等于正确答案
corrects.add("" + index);
else
wrongs.add("" + index);
// 检查重复
rightAns = new Number(rightAnsStr);
if (hasSameAnswer(rightAnswers, rightAns) && (duplicate = findDuplicate(expressions, exp)) != -1)
// 暂存重复的两个表达式的下标和对象
repeats.add(new ExpressionPair(duplicate + 1, index, expressions.get(duplicate), exp));
expressions.add(exp);
rightAnswers.add(rightAns);
index++;
}
测试运行
运行截图,耗时还是比较长……
这里因为÷号不是ascii所以读取出来的是乱码,需要加上 -Dfile.encoding=utf-8
参数
算式、答案、成绩文件
现在的查重还是做不到检测交换顺序后的重复,只能检测到数字顺序一样并且运算符和优先级一致的算式,具体来说就是去掉或加上括号的那种重复
PSP
PSP2.1 | Personal Software Process Stages | Time Predicted | Time
- | - | :-: | :-:
Planning | 计划 | 5 | 5
· Estimate | 估计这个任务需要多少时间 | 5 | 5
Development | 开发 | 420 | 973
· Analysis | 需求分析 (包括学习新技术) | 10 | 15
· Design Spec | 生成设计文档 | - | -
· Design Review | 设计复审 | - | -
· Coding Standard | 代码规范 | - | -
· Design | 具体设计 | - | -
· Coding | 具体编码 | 200 | 491
· Code Review | 代码复审 | 10 | 270
· Test | 测试(自我测试,修改代码,提交修改) | 200 | 197
Reporting | 报告 | 100 | 94
. | 测试报告 | 90 | 94
. | 计算工作量 | 10 | -
. | 并提出过程改进计划 | - | -
做这个项目还是花了很多时间的,虽然明明可以把功能完成得差不多就得了,但是为什么还要这么拼呢,对啊为什么呢……
大概是因为完美主义吧,就像我玩游戏一定要做全成就一样
这个表格的时间我也算得比较严格,误差应该在30分钟之内
具体编码这部分虽然一开始就觉得会花很久,结果最后花的时间还是比预期的要多
测试这部分虽然没有超出估计值,但还是比我之前做项目时花的时间多得多,也是第一次用了单元测试,感觉太棒了,非常好用啊
后来还花了3个半小时来写文档和注释(大部分时间在查单词- -),不知道该归类在哪,就写到代码复审里了
还有一些步骤我没做或者不知道哪些属于它,就没写时间了
另外,最烦人的部分是git,带着各种匪夷所思的错误,为了git的一个push花了3个小时,还是在寂寞的半夜
最后写博客花的时间我没记,不过加起来也得有3、4个小时吧
小结
这个项目虽然耗费了我大量娱乐时间,但也让我学到了很多有用的东西
比如JUnit,以前我都懒得写测试,但现在我第一次体会到了单元测试的好处,再也不用为了测一段代码就得反复把整个程序跑起来,还各种模拟操作了
再比如javadoc,这是我第一次这么认真地写javadoc,也基本了解了它的语法和一些表达习惯
还有git,虽然第一次配置配得我头都快秃了,但配完之后用起来还是很好用的,对代码管理也是大有帮助