一、简介
- 此项目是一个可自动生成小学生四则运算题目和答案、并可以对给定题目和答案文件进行批改的程序。
- GitHub地址:https://github.com/softwareCQT/cz_cqt/tree/master/src/com/czAcqt
- 注:控制台版本程序可直接点击.exe文件运行;图型用户界面程序需要通过命令行运行jar包(out/artifacts/arithmetic_jar/arithmetic.jar)来运行。
- 项目结对作者:柴政、陈起廷(软工一班)
二、PSP表
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 1200 | 1680 |
· Estimate | · 估计这个任务需要多少时间 | 1200 | 1680 |
Development | 开发 | 1000 | 1540 |
· Analysis | · 需求分析 (包括学习新技术) | 120 | 240 |
· Design Spec | · 生成设计文档 | 30 | 40 |
· Design Review | · 设计复审 (和同事审核设计文档) | 20 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 10 |
· Design | · 具体设计 | 30 | 70 |
· Coding | · 具体编码 | 600 | 1000 |
· Code Review | · 代码复审 | 100 | 120 |
· Test | · 测试(自我测试,修改代码,提交修改) | 70 | 30 |
Reporting | 报告 | 80 | 90 |
· Test Report | · 测试报告 | 30 | 20 |
· Size Measurement | · 计算工作量 | 20 | 30 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 30 | 40 |
合计 | 1400 | 1680 |
三、题目叙述
题目数字以及运算符要求:
- 真分数:1/2, 1/3, 2/3, 1/4, 1’1/2, …。
- 自然数:0, 1, 2, …。
- 运算符:+, −, ×, ÷。
- 括号:(, )。
- 等号:=。
- 分隔符:空格(用于四则运算符和等号前后)。
- 算术表达式:
e = n | e1 + e2 | e1 − e2 | e1 × e2 | e1 ÷ e2 | (e),
其中e, e1和e2为表达式,n为自然数或真分数。
- 四则运算题目:e = ,其中e为算术表达式。
生成题目具体操作过程及格式:
-
使用 -n 参数控制生成题目的个数,例如: Myapp.exe -n 10 将生成10个题目。
-
使用 -r 参数控制题目中数值(自然数、真分数和真分数分母)的范围,例如 :Myapp.exe -r 10 将生成10以内(不包括10)的四则运算题目。该参数可以设置为1或其他自然数。该参数必须给定,否 则程序报错并给出帮助信息。
-
生成的题目中计算过程不能产生负数,也就是说算术表达式中如果存在形如e1− e2的子表达式,那么e1≥ e2。
-
生成的题目中如果存在形如e1÷ e2的子表达式,那么其结果应是真分数。
-
每道题目中出现的运算符个数不超过3个。
-
程序一次运行生成的题目不能重复,即任何两道题目不能通过有限次交换+和×左右的算术表达式变换为同一道题目。例如,23 + 45 = 和45 + 23 = 是重复的题目,6 × 8 = 和8 × 6 = 也是重复的题目。3+(2+1)和1+2+3这两个题目是重复的,由于+是左结合的,1+2+3等价于(1+2)+3,也就是3+(1+2),也就是3+(2+1)。但是1+2+3和3+2+1是不重复的两道题,因为1+2+3等价于(1+2)+3,而3+2+1等价于(3+2)+1,它们之间不能通过有限次交换变成同一个题目。
-
生成的题目存入执行程序的当前目录下的Exercises.txt文件,格式如下:
- 四则运算题目1
- 四则运算题目2
...
其中真分数在输入输出时采用如下格式,真分数五分之三表示为3/5,真分数二又八分之三表示为2’3/8。
-
在生成题目的同时,计算出所有题目的答案,并存入执行程序的当前目录下的Answers.txt文件,格式如下:
1.答案1
2.答案2
... -
真分数的运算如下例所示:1/6 + 1/8 = 7/24。
-
程序应能支持一万道题目的生成。
-
程序支持对给定的题目文件和答案文件,判定答案中的对错并进行数量统计,输入参数如下:
Myapp.exe -e
Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)
其中“:”后面的数字5表示对/错的题目的数量,括号内的是对/错题目的编号。为简单起见,假设输入的题目都是按照顺序编号的符合规范的题目。
四、效能分析
- 规范化的四则表达式的检索,耗费了很多思考时间;
- 对于表达式的计算,采用了将中缀表达式转化成后缀表达式,并通过栈存储结构进行处理;
- 对于表达式的去重,采用了“同一个算式内当各个数字相同、符号相同、且结果相同时即算作重复”的去重方式。
- 程序最耗时间的是保存,因此每次将待保存的数据用集合装起来后一并写入文件,提高效率。
五、设计思路和设计过程感受
5.1架构分块
- 第一次设计分析:
- 最终设计流程:
5.2分工
- 类:
- 命令解析: CommandAnalyze-----柴政
- 表达式生成:Expression -----起廷
- 表达式计算:Calculate -----起廷
- 答案校验: AnswerChecking-----柴政
- 数据存储: DataStorage -----柴政
- 图型类:Graph -----柴政
- 接口:
- 计算生成:CalculateGenerate----起廷
- 枚举类:
- 运算符: Symbol ----起廷
5.2生成题目和计算题目的思路
-
数字生成:设计分为整数和分数两种类型,在生成分数的过程中检索是否有分母为0或者分子为零的情况,进行迭代生成。
-
符号生成:我的设计是操作符和括号是分开的。操作符是1/4的概率生成,而括号我选择随机选取表达式的特定位置做插入。
-
划分:我采取的措施是用数字和符号用空格划分,恰好也满足了项目的需求
-
题目计算和去重:题目去重和计算是同步的,通过把题目和计算出来的答案和存储好的题目和答案进行计算,检索答案是否一致再去判断符号类型、数字格式来判断题目是否重复,双重保障。计算是通过把生成好的表达式转换成后缀表达式来得到结果,这个简单,不摊开来讲。去重伪代码如下:
if (容器存在一毛一样的表达式){ 重新生成表达式 } if (容器里存在一毛一样的结果){ if (判断数字类型、符号是否完全符合,判断顺序是否有变换){ 重新生成表达式 } }
-
运算过程产生负数的处理:在计算过程中,碰到产生负数的结果,直接表达式计算返回,然后重新生成新的表达式。
5.3存储题目和答案以及校验的思路
- **存储表达式和答案:将表达式和答案的List分别转换为字符串用缓冲流一行一行写入文件,注意控制索引以及编码设置为"UTF-8"保证了不会乱码。
- **批改用户的答案:首先对指定题目文件和待校验答案文件进行判断,若题目文件为系统曾生成的文件,则调用对应版本号的答案文件对用户的答案文件进行校对;若题目文件为新文件,则调用程序的计算功能得到正确答案的answerList,用该answerList对待校验答案进行校对。将校对信息存为correctList和wrongList存入Grade.txt文件。
5.3在编码过程中碰到的问题
- 陈起廷:主要是人懒吧,脑子也笨,在编码的时候不专心,所以浪费了挺多时间的,整个设计起来不算很难,一些算法的实现也有自己的见解,困难都被勤奋打倒了。
(一开始觉得很难,写着写着突然就写完了这种感觉) - 柴政:起廷确实懒。这从我与他的问题总结中便可略窥一二,当然也从侧面说明起廷对于项目编写的熟练以及使用数据结构的熟练。
分工分到解析用户命令类以及存储数据类后写了一个晚上就差不多写完了,后来遇到如下问题:
1.项目第一次对接时决定更改传递参数的类型(【Map】:key:题目,value:答案 ---更改--->【List】:题目,【list】:答案);
2.发现第一次分析需求时忽略了校验答案的功能,于是用了一天时间进行修改与完善;
3.再次对接时被起廷指出了不少代码不规范的地方,经提醒修改了一些方法的访问权限以及变量的作用域,增加了程序的安全性并减少了消耗;
4.耗时最多的地方在于初次结对编程合作使用Github,花费很长时间进行配置和熟悉,可能由于网络等问题多次pull和merge失败,这里非常感谢起廷的多次远程指导;
5.编写UI界面用了大半天时间,由于没有使用组件的拖拽功能而是手动编码,遇到许多小问题,于是面向CSDN编程,过程实在痛苦;
6.最痛苦的是编译运行测试无误后打成jar包再转成exe可执行文件后发现图片资源无法保存,寻求解决办法后最后结果是不将jar包转成exe文件,直接使用命令行运行jar文件来使用。
六、关键代码
-
对分数及整数的计算
/*** * 相加操作 */ ADD("+") { @Override public String calculate(String a, String b) { boolean flagA = a.contains("/"); boolean flagB = b.contains("/"); //两个都是分数 if (flagA && flagB) { int[] anInt = ResolveUtil.analysis(a); int[] bnInt = ResolveUtil.analysis(b); //以AB为分母 int denominator = anInt[1] * bnInt[1]; //相加后的分子 int molecule = anInt[0] * bnInt[1] + anInt[1] * bnInt[0]; return ResolveUtil.createFraction(molecule, denominator); } else if (flagA) { int[] anInt = ResolveUtil.analysis(a); //直接更新分子便可 anInt[0] += Integer.parseInt(b) * anInt[1]; return ResolveUtil.createFraction(anInt[0], anInt[1]); } else if (flagB) { int[] bnInt = ResolveUtil.analysis(b); //直接更新分子便可 bnInt[0] += Integer.parseInt(a) * bnInt[1]; return ResolveUtil.createFraction(bnInt[0], bnInt[1]); } else { return String.valueOf(Integer.parseInt(a) + Integer.parseInt(b)); } } }, /*** * 相乘操作 */ MULTIPLY("×") { @Override public String calculate(String a, String b) { boolean flagA = a.contains("/"); boolean flagB = b.contains("/"); if (flagA && flagB) { int[] anInt = ResolveUtil.analysis(a); int[] bnInt = ResolveUtil.analysis(b); //以AB为分母 int denominator = anInt[1] * bnInt[1]; //分子相乘 int molecule = anInt[0] * bnInt[0]; return ResolveUtil.createFraction(molecule, denominator); } else if (flagA) { int[] anInt = ResolveUtil.analysis(a); return ResolveUtil.createFraction(anInt[0] * Integer.parseInt(b), anInt[1]); } else if (flagB) { int[] bnInt = ResolveUtil.analysis(b); return ResolveUtil.createFraction(bnInt[0] * Integer.parseInt(a), bnInt[1]); } else { return String.valueOf(Integer.parseInt(a) * Integer.parseInt(b)); } } }, /*** * 相除操作 */ DIVIDE("÷") { @Override public String calculate(String a, String b) { //除法,从另外一种角度来说,是乘法的倒转,所以,只需要把b分子分母倒过来用乘法就行了 boolean flag = b.contains("/"); //新的数b的字符串 String newB; //判断b是否为分数 if (flag) { int[] bnInt = ResolveUtil.analysis(b); newB = ResolveUtil.createFraction(bnInt[1], bnInt[0]); } else { newB = 1 + "/" + b; } return Symbol.MULTIPLY.calculate(a, newB); } }, /*** * 相减操作 */ SUB("-") { @Override public String calculate(String a, String b) { //减是加的特例,把b弄成-就可以了 return Symbol.ADD.calculate(a, "-" + b); } },
-
中缀转后缀表达式
/*** * 中缀表达式转换成后缀表达式 * @param expression 表达式 * @return 数组 */ private String[] middleToAfter(String expression) { //用来转换的栈 Stack<String> stack = new Stack<>(); //表达式每个字符前后都会生成一个空格 String[] strings = expression.split("\\s"); //返回的list List<String> stringList = new ArrayList<>(strings.length); for (int index = 0; index < strings.length; index++) { if ('0' <= strings[index].charAt(0) && strings[index].charAt(0) <= '9') { //数字直接输出 stringList.add(strings[index]); } else if (strings[index].equals(Symbol.BEGIN.getSymbol())) { //开始括号压进栈 stack.push(strings[index]); } else if (strings[index].equals(Symbol.END.getSymbol())) { //把所有运算符都出栈 while (!stack.peek().equals(Symbol.BEGIN.getSymbol())) { stringList.add(stack.pop()); } //出栈开始括号 stack.pop(); } else if (strings[index].equals(Symbol.MULTIPLY.getSymbol()) || strings[index].equals(Symbol.DIVIDE.getSymbol())) { //判断上一级符号是什么 boolean flag = !stack.isEmpty() && (stack.peek().equals(Symbol.MULTIPLY.getSymbol()) || stack.peek().equals(Symbol.DIVIDE.getSymbol())); if (flag) { stringList.add(stack.pop()); } stack.push(strings[index]); } else if (strings[index].equals(Symbol.SUB.getSymbol()) || strings[index].equals(Symbol.ADD.getSymbol())) { //此处应该为+,-号 boolean flag = !stack.isEmpty() && (stack.peek().equals(Symbol.ADD.getSymbol()) || stack.peek().equals(Symbol.SUB.getSymbol())); if (flag) { stringList.add(stack.pop()); } stack.push(strings[index]); } else { //有其他符号,直接跳出,可能是=号 break; } } while (!stack.isEmpty()) { stringList.add(stack.pop()); } //返回数组 return stringList.toArray(new String[0]); }
-
后缀计算
/*** * 计算表达式 * @param expression 表达式 * @param permit 允许存在负数的运算过程 * @return 结果 */ public String calculate(String expression, boolean permit) { if (expression == null) { return null; } //先生成后缀表达式数组,然后手动控制数组进行操作 String[] afterExp = middleToAfter(expression); Stack<String> stack = new Stack<>(); try { for (int index = 0; index < afterExp.length; index++) { if (afterExp[index].matches("[0-9/']+")) { stack.push(afterExp[index]); } else { String b = stack.pop(); String a = stack.pop(); String result = Symbol.value(afterExp[index]).calculate(a, b); //计算过程中存在负数,重新生成表达式 if (result.startsWith("-") && !permit) { return null; } stack.push(result); } } } catch (NullPointerException e) { e.printStackTrace(); } catch (Exception e) { System.out.println("存在表达式不合法"); } return stack.pop(); }
-
生成表达式
/*** * 生成表达式 * @return 生成表达式 */ private String generateExpression() { //随机运算符大小 int operatorSize = (int) (Math.random() * MAX_OPERATOR_SIZE) + 1; int numberSize = operatorSize + 1; //判断是否需要生成括号,1/4的概率 boolean flag = (int) (Math.random() * MAX_OPERATOR_SIZE) == 0; //标记(产生的位置) int mark = -1; if (flag) { //随机插入括号的位置 mark = (int) (Math.random() * operatorSize); } StringBuilder expression = new StringBuilder(); //遍历产生数字和符号,你一下我一下 for (int i = 0; i < numberSize; i++) { if (mark == i) { myAppend(expression, "("); } //生成数字 myAppend(expression, (int) (Math.random() * 2) == 0 ? generateFraction() : generateInt()); //判断是否加入结束符号,判断是否结尾 if (mark >= 0 && mark < i) { //已经到了表达式结尾, 此时必须结束 if (i == operatorSize) { myAppend(expression, ")"); break; } //判断是否需要结束 flag = (int) (Math.random() * 2) == 0; if (flag) { myAppend(expression, ")"); mark = -1; } } if (i < operatorSize) { //然后生成一个操作符 myAppend(expression, generateOperator()); } } //最后补充等号 expression.append("="); return expression.toString(); }
-
去重校验
/*** * 检查表达式是否已经存在或者重复 * @param expression 表达式 * @param result 结果 * @return 是否重复 */ private boolean checkExpressionExistAndResultIllegal(String expression, String result) { if (Objects.isNull(result)) { return true; } //当前没有表达式 if (nowExpressionSize == 0) { return false; } //API的一些操作也是循环,效率低下,手动循环 for (int i = 0, j = nowExpressionSize - 1; i <= j; i++, j--) { if (expressionList.get(i).equals(expression) || expressionList.get(j).equals(expression)) { return true; } //查看是否答案有相同的 if (answerList.get(i).equals(result)) { return checkCharEquals(expressionList.get(i), expression); } else if (answerList.get(j).equals(result)) { return checkCharEquals(expressionList.get(j), expression); } } return false; } /*** * * @param oldExpression 存在的表达式 * @param newExpression 还没存进去的表达式 * @return 相同,不相同 */ private boolean checkCharEquals(String oldExpression, String newExpression) { String[] oldExpressionArrays = oldExpression.split("\\s+"); String[] newExpressionArrays = newExpression.split("\\s+"); int oldExpressionNumber = 0; int equalsNumber = 0; //是否为数字 boolean flag; //开始遍历 for (String oldString : oldExpressionArrays) { flag = oldExpression.matches("[0-9'/]+"); if (flag) { oldExpressionNumber++; } //比对 for (String newString : newExpressionArrays) { if (oldString.equals(newString)) { equalsNumber++; } } }
-
答案校对
//保存正确/错误题号的队列 private List<Integer> correctList = new ArrayList<>(); private List<Integer> wrongList = new ArrayList<>(); /** * 校验待检测文件状态 * @param exersicesFile * @param myAnswer * @Author Naren */ public void checkFile(String exersicesFile,String myAnswer) { //存储表达式的文件 File expFile = new File(exersicesFile);//Exercise002.txt //待校验答案的文件 File myAnsFile = new File(myAnswer);//myAnswers001.txt //待校验答案文件不存在 if(!myAnsFile.exists()){ //System.out.println("未找到待检验答案文件。"); new Tips().displayTips("noExpTip.png"); return; } //如果表达式文件不存在 if(!expFile.exists()) { //System.out.println("未找到指定题目文件。"); new Tips().displayTips("noAnsTip.png"); return; } //如果全部文件名都正确,检测待校对题目文件是否存在于系统生成历史中 String id = exersicesFile.substring(9,12); String sysAnsFile = "Answers" + id + ".txt"; //Myapp.exe -e Exercises001.txt -a myAnswers001.txt File ansFile = new File(sysAnsFile);//Answers002.txt(不存在) //若系统中不存在与题目文件相符合的答案文件 if(!ansFile.exists()){ try { FileReader fr = new FileReader(expFile); BufferedReader br = new BufferedReader(fr); String content = null; ArrayList<String> questionList = new ArrayList<>(); while((content = br.readLine()) != null){//(?m)^.*$ content = content.split("\\.")[1]; questionList.add(content); } //调用起廷方法获得答案队列 Expression ex = new Expression(new Calculate()); List<String> answerList = ex.getCorrectAnswerList(questionList); //比对 checkAnswer(myAnsFile,answerList); } catch (Exception e) { System.out.println("Class:AnswerChecking,Method:checkFile(String,String) is wrong!"); } }else{ //调用本类检验方法比对答案文件 checkAnswer(myAnsFile,ansFile); } } /** * 将待校验答案文件与现场计算出的答案队列进行比对 * @param myAnswer * @param answerList * @author Naren */ private void checkAnswer(File myAnswer, List<String> answerList){ try { //待检测答案文件 FileReader fr1 = new FileReader(myAnswer); BufferedReader br1 = new BufferedReader(fr1); LinkedHashMap<Integer,String> map1 = new LinkedHashMap<>(); String content = ""; while((content = br1.readLine()) != null){ content = content.replaceAll(" +", "").replaceAll("\uFEFF", ""); //map1待校验答案:key:序号,value:答案 map1.put(Integer.valueOf(content.split("\\.")[0]),content.split("\\.")[1]); } //开始比对 for (int i = 0; i < answerList.size(); i++) { if(map1.containsKey(i + 1)){ if(map1.get(i + 1).equals(answerList.get(i))) { correctList.add(i + 1);//正确题号添加进队列 } else{ wrongList.add(i + 1);//错误题号添加进队列 } }else{ //漏写 wrongList.add(i + 1); } } //将校验结果写入文件 //System.out.println("检验信息已写入Grade.txt文件。"); new Tips().displayTips("checkSuccess.png"); new DataStorage().storeCheckInfo(correctList,wrongList); } catch (Exception e) { System.out.println("Class:AnswerChecking,Method:checkAnswer(File,List) is wrong!"); } } /** * 【重载】将待校验答案文件与本地答案文件进行比对 * @param myAnswer 待校验答案文件 * @param ansFile 正确答案文件 * @author Naren */ private void checkAnswer(File myAnswer, File ansFile) { try { FileReader fr1 = new FileReader(myAnswer); FileReader fr2 = new FileReader(ansFile); BufferedReader br1 = new BufferedReader(fr1); BufferedReader br2 = new BufferedReader(fr2); LinkedHashMap<Integer,String> map1 = new LinkedHashMap<>();//待检测 key:序号,value:答案 LinkedHashMap<Integer,String> map2 = new LinkedHashMap<>();//正 确 key:序号,value:答案 //分别按行读出答案 String content = ""; while((content = br1.readLine()) != null){ content = content.replaceAll(" +", "").replaceAll("\uFEFF", ""); map1.put(Integer.valueOf(content.split("\\.")[0]),content.split("\\.")[1]); } while((content = br2.readLine()) != null){ content = content.replaceAll(" +", "").replaceAll("\uFEFF", ""); map2.put(Integer.valueOf(content.split("\\.")[0]),content.split("\\.")[1]); } //开始比对 for (int i = 0; i < map2.size(); i++) { if(map1.containsKey(i + 1)){ if(map1.get(i + 1).equals(map2.get(i + 1))) { correctList.add(i + 1);//正确题号添加进队列 } else{ wrongList.add(i + 1);//错误题号添加进队列 } }else{ //漏写 wrongList.add(i + 1); } } //将校验结果写入文件 //System.out.println("检验信息已写入Grade.txt文件。"); new Tips().displayTips("checkSuccess.png"); new DataStorage().storeCheckInfo(correctList,wrongList); } catch (Exception e) { System.out.println("Class:AnswerChecking,Method:checkAnswer(File,File) is wrong!"); } }
七、测试结果
7.1控制台界面测试:
7.1.1错误示例
-
生成错误
- 【参数】错误
- 【文件已存在】错误
- 【参数】错误
-
校对错误
- 【题目文件不存在】错误
- 【答案文件不存在】错误
- 【题目文件不存在】错误
7.1.2正确示例
-
正确生成
-
正确生成100道题目
-
正确生成1万2千道题目
-
-
正确校验
- 正确校验100道题目
- 正确检验1万2千道题目
- 正确校验100道题目
7.2图型界面测试(命令行运行jar包)
7.2.1错误示例
7.2.2正确示例
- 正确生成
- 正确校对
因为编码过程中即有不断测试,且两个人互相挑bug,最后测试时手动覆盖了可能的输入,可以说“程序结果是正确的”的期望接近1。
八、过程感悟
- 陈起廷:结对挺好的,把不想做的都扔给另外一个人,还可以假正言辞(开玩笑)。代码完成度还算可以,也不是第一次和别人开发了,每一次从github拖下来的感觉不是代码,是惊吓。在整个项目过程中也用上了自己学习过的一些简化代码的知识点,有所收获。
- 柴政:第一次合作完成一个项目,虽然功能不多,但是在成功后还是感到一种与他人配合完成一件事的满足感。在编程与优化的过程中,我对项目的整体架构把握得更加熟练,并且得益于队友的指点增加了美观代码和性能的技巧。两个人的对接是一个互相找bug的过程(笑),不过最后都能得到很好的解决。希望下次的项目中能更加注重对时间的规划和记录,以及更加深入对Java的底层机制的了解。
ps.花絮【"I push, you..."】
- 起廷:此bug已修除~