1. GitHub 地址
本项目由 莫少政(3117004667)、余泽端(3117004679)结对完成。
项目 GitHub 地址:https://github.com/Yuzeduan/Arithmetic.git
2. PSP 表格
PSP2.1表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 20 | 25 |
· Estimate | · 估计这个任务需要多少时间 | 20 | 25 |
Development | 开发 | 890 | 635 |
· Analysis | · 需求分析 (包括学习新技术) | 60 | 30 |
· Design Spec | · 生成设计文档 | 60 | 40 |
· Design Review | · 设计复审 (和同事审核设计文档) | 30 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 20 | 15 |
· Design | · 具体设计 | 30 | 30 |
· Coding | · 具体编码 | 600 | 420 |
· Code Review | · 代码复审 | 30 | 30 |
· Test | · 测试(自我测试,修改代码,提交修改) | 60 | 40 |
Reporting | 报告 | 130 | 160 |
· Test Report | · 测试报告 | 80 | 100 |
· Size Measurement | · 计算工作量 | 20 | 20 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 30 | 40 |
合计 | 1040 | 820 |
3. 效能分析
初始时,生成一万道题目和答案所需时间约 4s 。
优化思路:由于 Java 中对象的初始化需要时间,因此在循环的时候,将变量的定义移到循环体外面,每次循环的时候复用已有的对象,从而避免了多次生成对象所消耗的时间。此外,相比于生成所有题目之后再一次性写入,便生成边写入文件,能避免一次性写入大量数据时文件读写缓冲区所耗费的时间。
优化后,生成一万道题目所花费时间约 0.9s 。
效能分析截图如下:
4. 设计思路
对题目进行分析之后,我们把项目总体分为两个大的功能模块,一个是生成题目以及生成答案,并写入到文件中,另一个是读取传入的参数名的文件题目以及答案,并进行题目的解答,校对答案,生成成绩,写入文件。
生成题目功能:首先需要判断参数,得到生成题目的个数,以及生成数的最大值,根据这两个参数来生成题目。第一步是生成操作符的个数,使用封装的随机数工具,获得操作符个数之后,生成相应数量的操作数,操作数有两种可能,一种是小数,一种是整数,因此需要随机生成两种类别,接着便是生成操作符,操作符有四种,同理也是这么操作,需要注意是,如果生成的是减号,为了使得计算过程中不能产生负数,因此需要生成数的时候,进行判断。最后一步,便是生成括号,括号插入到生成的题目中。
生成答案功能:首先需要判断有没有括号,如果有括号,应该先递归计算里面的子表达式,然后需要将真分数转化为小数,以及有个问题便是算术符号优先级不同,需要进行两次遍历,第一次只处理乘法和除法,第二次处理加法减法。
操作数应该封装成一个实体类,里面包含数值还有其前面的操作符,如果是第一个操作数,则其操作符属性为空。在生成题目和解析题目的时候,将每个操作数对象填充在一个容器中,进行统一管理。
5. 设计实现过程
- Base 功能模块
- FileUtil 类,其中提供 read,write 方法,进行文件的读取
- RandomUtil 类,对随机数生成进行封装,构建通用的生成随机数工具
- DecimalUtil 类,对小数,分数,真分数等进行转换
- 逻辑模块
- DataProvider 类:通过获取功能模块的数据,进行相应的逻辑处理,返回数据给调用的方案处理类
- QuestionService 类:进行生成题目相关的业务逻辑,并执行文件读写操作
- GradeService 类:进行题目和答案校对,生成分数的业务罗技,并执行读写操作
6. 关键代码说明
public String read() {
try {
String data = inputStream.readLine();
if (data != null) {
return data;
} else {
return null;
}
} catch (IOException e) {
e.printStackTrace();
return null;
}
}public void write(String data) {
outputStream.println(data);
}
进行文件的读取和写出,使用 Java 提供的 BufferedReade r和 PrintWriter 进行文件操作。
public static float toFloat(String str) {
String[] strs = str.split("’");
if (strs.length == 1) {
String[] twoNum = strs[0].split("/");
return twoNum.length == 1 ? Float.valueOf(twoNum[0]) : (Float.valueOf(twoNum[0]) / Float.valueOf(twoNum[1]));
} else {
String[] twoNum = strs[1].split("/");
return Integer.valueOf(strs[0]) + Float.valueOf(twoNum[0]) / Float.valueOf(twoNum[1]);
}
}
读取文件时候,需要将真分数进行转化为小数,进行数值的处理,此处采用 String 类提供的 api 进行字符串处理,获取其数值。
public static String toStr(float dec) {
String[] strs = (dec + "").split("\\.");
if (strs[1].length() > 6) {
strs[1] = strs[1].substring(0, 6);
}
if (strs[1].contains("E")) return null;
int integer = Integer.valueOf(strs[0]);
int decimal = Integer.valueOf(strs[1]);
if (decimal == 0) return integer + "";
int mother = (int) Math.pow(10, strs[1].length());
int divisor = getMaxDivisor(decimal, mother);
return (integer > 0 ? integer + "’" : "") + decimal / divisor + "/" + mother / divisor;
}
将小数转换为分数,并且对过于小的数字,进行精度的截断,构建出真分数,并采用辗转相除法进行分数的化简。
public static String getAnswer(String question) {
question = question.replace(EQU, "");
if (question.contains("(")) {
int leftIndex = question.indexOf("(");
int rightIndex = question.indexOf(")");
String subQuestion = question.substring(leftIndex + 1, rightIndex);
if (subQuestion == null) {
System.out.println(1);
}
String subAnswer = getAnswer(subQuestion);
if (subAnswer == null) {
return null;
}
question = question.replace("(" + subQuestion + ")", subAnswer);
}
String[] preStrs = question.split(" ");
List<String> strs = new ArrayList<>();
if (preStrs.length == 1) {
return question;
}
for (int i = 1; i < preStrs.length; i += 2) {
if (preStrs[i].equals("×")) {
preStrs[i + 1] = DecimalUtil.toFloat(preStrs[i - 1]) * DecimalUtil.toFloat(preStrs[i + 1]) + "";
} else if (preStrs[i].equals("÷")) {
if (DecimalUtil.toFloat(preStrs[i + 1]) == 0) {
return null;
}
preStrs[i + 1] = DecimalUtil.toFloat(preStrs[i - 1]) / DecimalUtil.toFloat(preStrs[i + 1]) + "";
} else {
strs.add(preStrs[i - 1]);
strs.add(preStrs[i]);
}
if (i == preStrs.length - 2) {
strs.add(preStrs[i + 1]);
}
}
if (strs.size() == 1) {
return DecimalUtil.toStr(Float.valueOf(strs.get(0)));
}
for (int i = 1; i < strs.size(); i += 2) {
if (strs.get(i).equals("+")) {
strs.set(i + 1, DecimalUtil.toFloat(strs.get(i - 1)) + DecimalUtil.toFloat(strs.get(i + 1)) + "");
} else {
float temp = DecimalUtil.toFloat(strs.get(i - 1)) - DecimalUtil.toFloat(strs.get(i + 1));
if(temp < 0) return null;
strs.set(i + 1, temp + "");
}
if (i == strs.size() - 2) {
return DecimalUtil.toStr(Float.valueOf(strs.get(i + 1)));
}
}
return null;
}
在生成题目答案的实现中,首先是判断有没有括号的存在,有的话,进行递归调用该方法,算出值之后,替换掉表达式中的括号子表达式,接着进行第一次遍历,算出所有乘法和除法,填充进容器中,进行第二次遍历,算出加减法,值得注意的是,会在这个过程中判断类似9 - ( 5 + 7 )
这种产生负数的情况,因为生成的时候,括号是最后生成的,此情况是会出现的,因此需要判断一下,如果出现该情况,就返回 Null 给上层,让其重新生成一道题目。
for (int question = 0; question < num; question++) {
Random random = new Random();
List<Number> nums = new ArrayList<>(); // 生成运算符数量
int operator = random.nextInt(3) + 1;
boolean isInt = false;
boolean hasParentheses = false;
String symbol = "";
float operateNum = -1;
for (int i = 0; i < operator + 1; i++) {
isInt = random.nextInt(2) == Constant.TYPE_INT;
symbol = i == 0 ? "" : SymbolList[random.nextInt(4)];
if (symbol.equals(SUB)) {
operateNum = isInt ? random.nextInt((int) (nums.get(i - 1).getNum()) + 1) : random.nextInt((int) ((nums.get(i - 1).getNum() + 0.01) * 100)) / 100.0f;
} else {
operateNum = isInt ? random.nextInt(max + 1) : random.nextInt((max + 1) * 100) / 100.0f;
}
if (symbol.equals(DIV)) {
operateNum = operateNum == 0 ? (isInt ? 1 : 0.01f) : operateNum;
}
nums.add(new Number(symbol, operateNum));
}
hasParentheses = random.nextInt(2) == 1;
int leftIndex = -1;
int rightIndex = -1;
if (hasParentheses) {
leftIndex = random.nextInt(operator);
rightIndex = random.nextInt(operator - leftIndex) + leftIndex + 1;
}
StringBuffer sb = new StringBuffer();
Number number;
for (int i = 0; i < nums.size(); i++) {
number = nums.get(i);
if (hasParentheses && i == leftIndex) {
sb.append(number.getSymbol())
.append("(")
.append(DecimalUtil.toStr(number.getNum()));
} else if (i == rightIndex) {
sb.append(number.getSymbol())
.append(DecimalUtil.toStr(number.getNum()))
.append(")");
} else {
sb.append(number.getSymbol()).append(DecimalUtil.toStr(number.getNum()));
}
}
sb.append(EQU);
String answer = DataProvider.getAnswer(sb.toString());
if (answer == null) {
question--;
continue;
}
questionFile.write(question + 1 + ". " + sb.toString());
answerFile.write(question + 1 + ". " + answer);
}
生成题目的时候,需要根据参数的题目数量进行判断循环次数,第一步是生成操作符的个数,使用封装的随机数工具,获得操作符个数之后,就进行生成操作数,操作数有两种可能,一种是小数,一种是整数,因此需要进行随机数生成两种类别,接着便是生成操作符,操作符有四种。将操作数填充进一个容器中,在最后转换成字符串时候,生成括号插入。
为了防止生成题目过多,导致写出文件出错,我们采用,在生成题目的开始,打开文件,每次生成一道题目,便写入,且算出答案。结束,关闭文件。
7. 测试运行
-
生成题目及答案 ( 5 个测试用例)
1、 -n 5 -r 10
2、 -n 10 -r 20
3、 -n 1000 -r 30
4、 -n 10000 -r 50
5、 -n 1000000 -r 100
-
批改题目 ( 5 个测试用例)
6、 5 道题目的批改
7、 10 道题目的批改(故意弄错 2 道题的答案)
8、 1000 道题目的批改(故意弄错 5 道题的答案)
9、 10000 道题目的批改
10、 1000000 道题目的批改
8. 项目总结
本次结对编程项目的结果,我们自认为较为成功,成功的原因主要是我们在编程之前一起讨论、进行设计,达成了功能实现上的共识,使得我们在后续的开发中心有灵犀,形成合力。
这次项目经历,让我们首次体验了这种“一边一个人写代码、一边另一个人 Code Review”的工作模式,非常新奇,也感到非常有趣。两个人讨论过、统一了思路之后,在结对编程中思路同步、相互提示并且传授编程思想,对技术进步非常有帮助。
莫少政:余泽端的闪光点在于技术非常厉害,在项目架构的设计上,分包、分层、容器等等很多工程化的规范的编程思想,使我受益匪浅。跟这样的大佬结对编程,能学到很多平时很难自己学到和摸索到的东西。
余泽端:莫少政的闪光点在于比较注重细节,有时候能留意到一些我疏忽的细节上的 bug 。