结对项目
这个作业属于哪个课程 | 计科22级12班软件工程 |
---|---|
这个作业要求在哪里 | 作业要求 |
这个作业的目标 | 与队友一起实现一个自动生成小学四则运算题目的命令行程序,学会协同工作 |
一、两个队员的信息与github地址
姓名 | 学号 | github地址 |
---|---|---|
张锦程 | 3122004414 | 点击跳转 |
张继凯 | 3120004853 | 点击跳转 |
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 30 | 25 |
Estimate | 估计这个任务需要多少时间 | 20 | 30 |
Development | 开发 | 200 | 220 |
Analysis | 需求分析 (包括学习新技术) | 60 | 30 |
Design Spec | 生成设计文档 | 20 | 30 |
Design Review | 设计复审 | 30 | 40 |
Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 10 | 5 |
Design | 具体设计 | 20 | 15 |
Coding | 具体编码 | 200 | 300 |
Code Review | 代码复审 | 30 | 20 |
Test | 测试(自我测试,修改代码,提交修改) | 30 | 40 |
Reporting | 报告 | 60 | 70 |
Test Repor | 测试报告 | 20 | 10 |
Size Measurement | 计算工作量 | 15 | 15 |
Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 20 | 30 |
合计 | 765 | 880 |
三、效能分析
本次的性能分析以生成10000道且操作数范围为10以内的四则运算题目为例。
1.生成的题目与答案分析
显然,可以看出在所显示的题目与答案中,计算结果是没问题的,因此程序的计算准确率是可以保证的。
2.本次程序运行耗时
由下图可知,在生成10000道操作数在10以内的不重复的四则运算题耗费了21s,这个结果对于本程序来说是可接受的,因为在查重算法里查的比较细所以需要消耗一点时间。
3.内存消耗
由下图可知,本次运行耗费最大内存为139MB,因为在生成表达式或者判断重复的时候都用了较多的字符串数组以及文件流,因此这个内存消耗相对来说是可观的;而对于第二张图的实时内存,是因为每生成一条表达式的时候,都会进行文件的写入,在文件流中多用byte,因此byte消耗较大,而对于表达式的计算与查重,用了较多的booleam方法进行判定结果以及字符串数组作为判定过程量,因此实时内存的消耗也是有迹可循的。
4.cpu负载
下图可知,本次运行给cpu并没有带来很严重的负载,最多的时候总负载是百分之四十左右,对于cpu来说简直洒洒水。
5.性能分析与改进思路
由以上的分析得出,虽然本程序在性能上的表现都是非常可观的,但是我们的程序还是有可以改进的地方。我们下一步打算在表达式查重上进行一些优化,比如在第一次搜寻是否结果重复的时候便可进行判定操作数是否重复,从而减少一些不必要的循环实现,还有就是对于文件写入,或许能一次写入从而来减少内存与时间的消耗。
四、模块的设计与实现
1.设计实现过程
Main类:程序的入口,负责解析命令行参数、调用生成和判断的逻辑。
-
run方法
:处理命令行参数并调用相应功能。 -
generate方法
: 生成满足条件且不重复的四则运算数学题目并写入文件。 -
isPass方法
:用于判断刚生成的表达式与之前表达式是否等效。 -
isSame方法
:用于判断两个结果重复的式子是否操作数也重复,若重复则视为两个相等的式子。 -
judge方法
:与表达式的参考答案进行对比,用于检查一个文件的答案是否正确。
Equation_p_c类:负责生成和计算数学表达式,包括对表达式的解析、运算和结果计算。
-
generateExpression方法
:用于生成一条指定运算符数量的限制操作数大小的四则运算题。 -
infixToPostfix方法
:将中缀表达式转后缀表达式,处理运算符优先级。 -
symbolPriority方法
:运算符优先级的判定。 -
calculatePostfix方法
:计算后缀表达式,从而得出结果,且确保中间结果不为负数。
file_work类:处理文件读写操作,包括写入题目和答案、清空文件、读取文件内容。
-
WriteFile方法
:将生成的满足条件的四则运算式子写入指定文件。 -
ReadFile方法
:读取指定文件的表达式,主要是用于judge方法。 -
ClearFile方法
:将指定文件内容清空。
fraction_work类:处理分数(包括自然数即分母为1)的创建和运算,包括分数的加减乘除和随机生成分数。
-
fraction_random方法
:可随机生成一个符合题目条件的操作数即真分数或者自然数。 -
operation方法
:根据传入的运算符,对后面传入的两个参数进行运算,并返回结果。 -
toString
方法:这是一个重构方法,主要是为了应对题目要求的数据输出表现方式。
2.类的调用关系
首先是Main类根据参数来选择生成表达式还是答案比对,如果是生成表达式则调用Equation_p_c类进行生成,在生成的过程中会调用fraction_work类来进行操作数生成协助生成表达式,当表达式符合条件后再调用file_work类进行题目与答案的写入;而对于答案比对,则会先调用file_work类进行题目与需要比对的答案的文件读取,然后再调用Equation_p_c类对表达式进行运算,从而进行答案比对,然后将比对结果(即对几道错几道)写入指定文件中。
3.关键函数流程图
五、代码说明
代码说明这部分主要解释一些关键方法的实现。
1.generate(int num, int max)
属于Main类中的一个方法,目的是生成num条且操作数范围在max以内的四则运算题。步骤是先随机生成运算符数目,然后调用Equarion_p_c类来生成一道题,然后将这道题的结果与之前题目的结果进行比对,如果有重复则将这两个式子的操作数进行比对,只要有一个不相同就直接写入文件,若操作数也都相同,则需重新生成表达式。
点击可展开代码查看
public static void generate(int num, int max) throws IOException {
int i = 0;
String[]result2=new String[num];
for (int m =0;m<num;m++){
result2[m]="-1";
}
file_work.ClearFile(exercisesFilePath);//先清空
file_work.ClearFile(answersFilePath);
System.out.println("生成中...请稍后");
while (i < num) {
int numSymbol = (int) (Math.random() * 3 + 1); // 随机生成1-3个运算符
Equation_p_c equation = new Equation_p_c(numSymbol, max); // 生成表达式
// 检查表达式是否有效
if (!isValidEquation(equation)) continue;//即结果不为0
String result = equation.getResult().toString();
if (result2[0]!="-1"&&!isPass(equation,result2,result)) {//判断结果有无重复,如果有重复就把重复的式子进行操作数比对,如果都相同才重新生成
System.out.println("重新生成"); continue;}
result2[i]=result;
String question = (i + 1) + ". " + equation.getInfixExpression() + "=";
result = (i + 1) + ". " + result;
file_work.WriteFile(exercisesFilePath, question); // 将题目写入文件
file_work.WriteFile(answersFilePath, result); // 将答案写入文件
i++;
}
System.out.println("生成"+num+"道操作数范围在"+max+"内的四则运算式完毕,已存入目标文件"+exercisesFilePath+"中。");
}
2.isSame(Equation_p_c equantion,int pointer2)
属于Main类的一个方法,目的是对重复的两个式子进行操作数比对,首先根据pointer2提供的角标将之前的结果相同的表达式提取出来,然后将这次的表达式equantion与之前的表达式利用正则表达式来划分出操作数,然后利用循环进行操作数比对,当出现一个操作数而对方没有时则可以返回结果False即这两个式子不等效,若操作数还全部相同,则返回True,即等效。
点击可展开代码查看
private static boolean isSame(Equation_p_c equantion,int pointer2){//对重复的式子进行操作数比对
File file=new File(exercisesFilePath);
String repeat_line;//用来存放之前结果重复的式子
try {
LineNumberReader lnr=new LineNumberReader(new FileReader(file));
while((repeat_line = lnr.readLine()) != null){
if(lnr.getLineNumber()==pointer2){//根据角标读取式子
break;
}
}
int index = repeat_line.indexOf(".");
repeat_line = repeat_line.substring(index + 1, repeat_line.length() - 1);//将题号删除
String[]repeat_equantion=repeat_line.split("(?=[+\\-×÷()])|(?<=[+\\-×÷()])");//下面这几句是用来筛选出两个式子中的操作数并装入字符串数组中
String[]equantion_now=equantion.toString().split("(?=[+\\-×÷()])|(?<=[+\\-×÷()])");
repeat_equantion= Arrays.stream(repeat_equantion)
.filter(part -> !part.matches("[+\\-×÷()\\s]*")) // 过滤掉运算符、空格等无效部分
.toArray(String[]::new);
equantion_now=Arrays.stream(equantion_now)
.filter(part -> !part.matches("[+\\-×÷()\\s]*")) // 过滤掉运算符、空格等无效部分
.toArray(String[]::new);
if (repeat_equantion.length!=equantion_now.length) return false;
for (int q=0;q<equantion_now.length;q++){
int biaozhi=0;
for (int p=0;p<repeat_equantion.length;p++){
if (!equantion_now[q].equals(repeat_equantion[p])) biaozhi++;
}
if(biaozhi==equantion_now.length) return false;
}
lnr.close();
} catch (FileNotFoundException e) {
System.out.println("判定重复时读取文件失败");
} catch (IOException e) {
System.out.println("判定重复时读取文件的行数失败");
}
return true;
}
3.fraction_random(int max)
属于fraction_work类的一个方法,用于生成一个小于max的自然数或者真分数,主要就是约束随机数不超出范围,将不满足条件的数进行处理。
点击可展开代码查看
public static fraction_work fraction_random(int max){//随机生成小于max真分数或者自然数
int p=(int)(Math.random()*100)+1;
if(p<=15){//15%的概率生成整数,因为自然数太多了
int fenzi=(int)(Math.random()*max);
if(fenzi==0)
fenzi=1;//防止0的生成
return new fraction_work(fenzi,1);
}
else{
int fenmu=(int)(Math.random()*max)+1;
if(fenmu==max&&fenmu!=1)
fenmu--;//防止分母超过范围
int numerator=(int)(Math.random()*fenmu*max)+1;//分子范围在1到分母*max之间
while(fenmu==1&&numerator==max) numerator=(int)(Math.random()*fenmu*max)+1;
if (numerator/fenmu==max) return fraction_random(max);//如果生成max则重新生成
return new fraction_work(numerator,fenmu);
}
}
4.toString()
这个是fraction_work类里重构的方法,主要是为了数据的格式根据题目的要求进行相应的约束与修改,使得更加满足题目要求,例如1'1/2这种形式。
点击可展开代码查看
@Override
public String toString() {
if (numerator == 0) return "0";
if (denominator == 1) return String.valueOf(numerator);
boolean isNegative = (numerator < 0) ^ (denominator < 0);
numerator = Math.abs(numerator);
denominator = Math.abs(denominator);
int g = gcd(numerator, denominator); // 使用最大公因数的方法化简
numerator /= g;
denominator /= g;
//对于分数的格式输出处理,使得满足题目要求。
if (numerator < denominator) {
return (isNegative ? "-" : "") + numerator + "/" + denominator;
} else {
int wholePart = numerator / denominator;
int remainder = numerator % denominator;
if (remainder == 0) {
return (isNegative ? "-" : "") + wholePart;
} else {
return (isNegative ? "-" : "") + wholePart + "'" + remainder + "/" + denominator;
}
}
}
5.generateExpression(int numSymbols, int max)
属于Equation_p_c类的一个方法,目的是生成一个含有numSymbols个运算符且操作数不大于max的四则运算题。题目的生成是顺序生成,先生成操作数然后运算符然后操作数这样的顺序,然后每个操作数之前会有括号的随机生成,且有有个distance变量来作为左括号与右括号的距离表示,从而避免了(1)这样的形式出现,只有当有第二个操作数以上时才可以随机生成右括号。若到最后结束了左括号还没闭合,则判定左括号的位置是否在首位,若是首位则删除首位左括号否则直接生成右括号闭合。
点击可展开代码查看
private String generateExpression(int numSymbols, int max) {
StringBuilder expression = new StringBuilder();
Random random = new Random();
int numOpenBrackets = 0; // 已插入的左括号数量
int numCloseBrackets = 0; // 已插入的右括号数量
boolean insideBrackets = false; // 标记当前是否在括号内
int biaozhi=0;
int distance=0;
// 生成符号和数字,并随机插入括号
for (int i = 0; i < numSymbols; i++) {
String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)];
fraction_work nextNumber= fraction_work.fraction_random(max);
if(i==0){
// 生成第一个数字
if (!insideBrackets && random.nextBoolean() && numOpenBrackets < numSymbols / 2) {
expression.append(" (");
numOpenBrackets++;
insideBrackets = true; // 标记进入括号内
distance+=1;
}
fraction_work firstNumber = fraction_work.fraction_random(max);
expression.append(firstNumber);
}
// 添加符号
expression.append(" ").append(symbol);
//随机决定是否插入左括号,但不能超过符号数一半且在最后一个操作数前不能生成左括号
if (!insideBrackets && random.nextBoolean() && numOpenBrackets < numSymbols / 2&&i+1<numSymbols) {
expression.append(" (");
numOpenBrackets++;
insideBrackets = true; // 标记进入括号内
}
//添加操作数
expression.append(" ").append(nextNumber);
if (distance!=0) distance+=1;
// 随机决定是否插入右括号,但必须成对关闭括号且最后一个操作数后应该由最后面的循环判定,这里直接不能生成
if (insideBrackets && random.nextBoolean() && numCloseBrackets < numOpenBrackets&&distance>=2&&i+1<numSymbols) {
expression.append(")");
distance=0;
numCloseBrackets++;
insideBrackets = false; // 关闭括号
}
}
// 如果有未关闭的括号,通过判定是否左括号在首位来决定是否闭合
while (numCloseBrackets < numOpenBrackets) {
String test=expression.toString().trim();
//首位为左括号,直接删除左括号,否则闭合
if (test.charAt(0)=='('&&numCloseBrackets+1==numOpenBrackets) {
biaozhi=1;
}else {
expression.append(")");
}
numCloseBrackets++;
}
if (biaozhi==1) {
String test=expression.toString().trim().substring(1);
return test;
}
return expression.toString().trim();
}
6.infixToPostfix(String infixExpression)
这个方法是Equation_p_c类中的一个方法,目的是将infixExpression这个中缀表达式转换成后缀表达式。主要是运算符的优先级处理,通过栈将已通过正则表达式分割出来的运算符与操作数进行重新置位,将运算符优先的往前置位,从而得到后缀表达式,方便后面的计算。
点击可展开代码查看
private String infixToPostfix(String infixExpression) {
StringBuilder result = new StringBuilder();// 用于存储最终输出的后缀表达式
Stack<String> stack = new Stack<>();// 用于存储运算符的栈
// 使用正则表达式将输入的中缀表达式按符号和数字分割成数组
String[] tokens = infixExpression.split("(?=[-+×÷()])|(?<=[-+×÷()])");
for (String token : tokens) {
// 如果当前标记是操作数(不是符号、括号且不为空),直接添加到结果中
if (!isSymbol(token) && !token.equals("(") && !token.equals(")") && !token.isEmpty()) {
result.append(token).append(" ");
} else if (token.equals("(")) {
stack.push(token);
} else if (token.equals(")")) {
// 弹出栈中的符号直到遇到左括号为止,将弹出的符号添加到结果中
while (!stack.isEmpty() && !stack.peek().equals("(")) {
result.append(stack.pop()).append(" ");
}
if (!stack.isEmpty()) {
stack.pop(); // 移除左括号
}
// 如果当前标记是运算符
} else if (isSymbol(token)) {
// 当栈不为空且栈顶符号的优先级高于或等于当前符号时,弹出栈顶符号并加入结果
while (!stack.isEmpty() && symbolPriority(stack.peek()) >= symbolPriority(token)) {
result.append(stack.pop()).append(" ");
}
// 将当前运算符压入栈中
stack.push(token);
}
}
// 如果栈中还有剩余的符号,依次弹出并加入结果
while (!stack.isEmpty()) {
result.append(stack.pop()).append(" ");
}
// 返回最终的后缀表达式,去掉多余的空格
return result.toString().trim();
}
7.calculatePostfix(String expression)
将后缀表达式expression通过一个栈不断弹出操作数与运算符来进行计算,且当运算结果不符合题目要求时则返回null,否则返回计算结果。
点击可展开代码查看
private fraction_work calculatePostfix(String expression) {
String[] tokens = expression.split(" ");
Stack<fraction_work> stack = new Stack<>();
for (String token : tokens) {
if (!isSymbol(token) && !token.isEmpty()) {
stack.push(new fraction_work(token)); // 操作数入栈
} else if (isSymbol(token)) {
if (stack.size() < 2) {
return null; // 表达式错误,重新生成
}
fraction_work b = stack.pop();
fraction_work a = stack.pop();
fraction_work result = fraction_work.operation(token, a, b);
// 如果中间结果为负数,停止并重新生成表达式
if (result.isNegative()) {
return null;
}
stack.push(result); // 继续计算
}
}
return stack.isEmpty() ? null : stack.pop(); // 最终结果
}
8.WriteFile(String path, String str)
属于file_work类的一个方法,目的使将符合条件的表达式str写入目标文件path中,写入前将str进行了空格删除从而使表达式更加紧凑。(对于读取文件的方法与写入相似,便不再赘述)
点击可展开代码查看
public static void WriteFile(String path, String str) throws IOException {
File file = new File(path);
if (!file.exists()) {
if (!file.createNewFile()) {
System.out.println("创建失败");
System.exit(1);
}
}
try (FileWriter fw = new FileWriter(file, true)) {
String str2=str.replaceAll(" ", "");//去所有空格
fw.write(str2 + "\n"); // 逐行写入文件
} catch (IOException e) {
throw new IOException("文件写入出现了错误");
}
}
六、测试以及运行结果
因为测试代码里用了assertEquals、assertTrue等进行测试是否通过判定,若没通过是会报错的,所以下面的测试没报错就是通过了。
1.Main单元模块测试
这个模块测试了生成表达式、判断答案时答案错、以及判断答案时答案对三种情况。
点击查看代码
public class MainTest {
private final String exercisesFilePath = "Exercises.txt";
private final String answersFilePath = "Answers.txt";
@BeforeEach
public void setUp() throws IOException {
file_work.ClearFile(exercisesFilePath);
file_work.ClearFile(answersFilePath);
}
@Test
public void testGenerateExercises() throws IOException {
Main.generate(5, 10);
List<String> exercises = new ArrayList<>();
List<String> answers = new ArrayList<>();
file_work.ReadFile(exercisesFilePath, exercises);
file_work.ReadFile(answersFilePath, answers);
assertEquals(5, exercises.size());
assertEquals(5, answers.size());
}
@Test
public void testInvalidArgumentCount() {
// 测试参数个数不合法的情况
String[] args = {"-n", "5", "-r", "10", "extraArg"}; // 这里的参数个数为 5
RuntimeException exception = assertThrows(RuntimeException.class, () -> {
Main.main(args); // 调用 main 方法来进行参数检查
});
assertEquals("输入的参数个数有误", exception.getMessage());
}
@Test
public void testJudgeMismatch() throws IOException {
file_work.WriteFile(exercisesFilePath, "2 + 3 =");
file_work.WriteFile(exercisesFilePath, "4 + 7 =");
file_work.WriteFile(answersFilePath, "6");
Exception exception = assertThrows(RuntimeException.class, () -> {
Main.judge(exercisesFilePath, answersFilePath);
});
assertEquals("题目数量与答案数量不一致", exception.getMessage());
}
@Test
public void testValidJudgment() throws IOException {
file_work.WriteFile(exercisesFilePath, "2 + 3 =");
file_work.WriteFile(answersFilePath, "5");
Main.judge(exercisesFilePath, answersFilePath); // 不会抛出异常
}
}
- 测试结果以及覆盖率
显然比较成功。
2.Equantion_p_C单元模块测试
这个模块主要测试了中缀转后缀是否正确、后缀计算是否正确以及出现负数时的处理。
点击查看代码
public class EquationTest {
@Test
public void testValidExpression() {
Equation_p_c equation = new Equation_p_c("3 + 5 × 2");
assertEquals("3 5 2 × +", equation.getPostfixExpression());
assertNotNull(equation.getResult());
assertFalse(equation.getResult().isNegative());
}
@Test
public void testExpressionWithBrackets() {
Equation_p_c equation = new Equation_p_c("(2 + 3) × 4");
assertEquals("2 3 + 4 ×", equation.getPostfixExpression());
assertNotNull(equation.getResult());
assertFalse(equation.getResult().isNegative());
}
@Test
public void testNegativeResultExpression() {
Equation_p_c equation = new Equation_p_c("5 - 10");
assertNull(equation.getResult()); // 结果应该为null,因为中间结果为负数
}
}
- 测试结果以及覆盖率
显然都通过了。
3.file_work单元模块测试
这个模块主要测试文件的写入、读取与清空。
点击查看代码
public class FileWorkTest {
private static final String TEST_FILE_PATH = "testFile.txt";
@AfterEach
public void tearDown() {
File file = new File(TEST_FILE_PATH);
if (file.exists()) {
file.delete(); // 删除测试文件
}
}
@Test
public void testWriteFile() throws IOException {
String content = "Hello World";
file_work.WriteFile(TEST_FILE_PATH, content);
List<String> lines = new ArrayList<>();
file_work.ReadFile(TEST_FILE_PATH, lines);
assertEquals(1, lines.size());
assertEquals("HelloWorld", lines.get(0)); // 验证写入内容去掉空格
}
@Test
public void testClearFile() throws IOException {
file_work.WriteFile(TEST_FILE_PATH, "Test Content");
file_work.ClearFile(TEST_FILE_PATH);
List<String> lines = new ArrayList<>();
file_work.ReadFile(TEST_FILE_PATH, lines);
assertEquals(0, lines.size()); // 验证文件已清空
}
@Test
public void testReadFileNonExistent() {
RuntimeException exception = assertThrows(RuntimeException.class, () -> {
file_work.ReadFile("nonExistentFile.txt", new ArrayList<>());
});
assertEquals("文件不存在", exception.getMessage()); // 验证异常信息
}
}
- 测试结果与覆盖率
显然结果与覆盖率很合理。
4.fraction_work单元模块测试
这个模块测试了对于分数格式的处理以及分数的加减乘除。
点击查看代码
public class FractionWorkTest {
@Test
public void testFractionCreation() {
fraction_work frac = new fraction_work("1/2");
assertEquals("1/2", frac.toString());
frac = new fraction_work("2'3/4");
assertEquals("2'3/4", frac.toString());
}
@Test
public void testFractionRandom() {
fraction_work randomFrac = fraction_work.fraction_random(10);
assertTrue(randomFrac.toString().matches("-?\\d+'?\\d*/\\d+|\\d+")); // 检查随机分数格式
}
@Test
public void testFractionOperations() {
fraction_work a = new fraction_work("1/2");
fraction_work b = new fraction_work("1/4");
fraction_work sum = fraction_work.operation("+", a, b);
assertEquals("3/4", sum.toString());
fraction_work difference = fraction_work.operation("-", a, b);
assertEquals("1/4", difference.toString());
fraction_work product = fraction_work.operation("×", a, b);
assertEquals("1/8", product.toString());
fraction_work quotient = fraction_work.operation("÷", a, b);
assertEquals("2", quotient.toString());
}
}
- 测试结果与覆盖率
显然,测试结果与覆盖率都很合理。
七、使用命令行运行
1.生成表达式
命令行运行java -jar mathgenerator.jar -n 10000 -r 10
结果很显然是成功的。
2.答案检查
命令行运行java -jar mathgenerator.jar -e Exercises.txt -a answers_test.txt
其中answers_test.txt中1就是错误的答案,很显然,结果很成功。
八、项目小结
我们这次结成的队是本就是两个好朋友,在一起做这个项目的时候,我觉得难点就是分工上,因为两个人都不希望双方多做了一些事,都希望自己能在这个队伍里发挥一些作用。对于团队协作,我们都是有问题直接抛出来找解决办法,如果距离太远就直接腾讯会议交流,一起思考怎么实现某些功能以及如何去改进,两个人都非常地主动与勤奋。而且结果也很显然,我们两个人的小队很成功,在这次的项目两个人都发挥着举足轻重的作用,都很感激对方的行动与付出。一个字,爽。