软件工程第三次作业——结对项目

个人项目作业 实现一个自动生成小学四则运算题目的命令行程序(也可以用图像界面,具有相似功能)
这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience/homework/13470
这个作业 https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience/homework/13470

1.基本信息

姓名 学号 github仓库链接
沈武钊 3123004713 https://github.com/kmg74/kmg74/blob/main/软件工程第三次作业——结对项目.zip
陈嘉煌 3123004694 https://github.com/Ender39831/3123004694Homework

2.PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 30
· Estimate · 估计这个任务需要多少时间 20 20
Development 开发 600 620
· Analysis · 需求分析 (包括学习新技术) 300 200
· Design Spec · 生成设计文档 45 40
· Design Review · 设计复审 (和同事审核设计文档) 30 25
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 15 14
· Design · 具体设计 40 45
· Coding · 具体编码 150 160
· Code Review · 代码复审 40 30
· Test · 测试(自我测试,修改代码,提交修改) 120 120
Reporting 报告 20 30
· Test Report · 测试报告 50 50
· Size Measurement · 计算工作量 20 20
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 30 40
合计 800 900

3. 效能分析

3.1 性能改进思路

确保程序在生成大量题目时的效率和稳定性,可以从以下3点优化性能:

  1. 表达式去重优化:使用unordered_set存储归一化后的表达式,利用哈希表的O(1)时间复杂度平均查找复杂度,避免生成重复表达式,减少无效循环。
  2. 分数计算轻量化:在Fraction类中,仅在四则运算时临时转为假分数计算,计算后立即化简,避免冗余的内存占用和重复计算(如reduce函数仅在构造和运算后调用)。
  3. 文件操作稳定性:通过getExeDirectory函数获取程序所在绝对路径,确保生成的Exercises.txtAnswers.txtGrade.txt能稳定存储在程序目录下,避免相对路径导致的文件找不到问题。

3.2 性能分析图

image

  • 分析结论:程序CPU主要消耗在generateExercises的循环生成逻辑(占比约65%),其中Expression::generate(表达式生成)和unordered_set::insert(去重)是关键耗时操作;Fraction::reduce(分数化简)占比约20%,因涉及GCD计算(递归实现),但递归深度浅(最大为分母大小,通常≤用户设置的range),性能影响可控。

4. 设计实现过程

4.1 代码组织结构

名称 核心职责
Fraction 处理分数的存储、化简、四则运算、随机生成、字符串转换(真分数/带分数)
Expression 生成随机表达式(含运算符、括号)、计算表达式结果、归一化表达式(用于去重)
getExeDirectory 获取程序所在绝对路径,确保文件路径正确
generateExercises 生成题目和答案文件,实现表达式去重
parseFraction 解析用户输入的分数字符串(支持整数、真分数、带分数)
checkAnswers 对比题目文件和答案文件,生成评分报告(正确/错误题目编号)
showHelp 显示程序用法(生成题目/校验答案的参数格式)

4.2 类间关系

  • 依赖关系Expression类依赖Fraction类——表达式的每个操作数、中间结果和最终结果均为Fraction类型,表达式的四则运算本质是调用Fraction的运算符重载函数。
  • 调用关系:工具函数generateExercises调用Expression生成表达式,checkAnswers调用parseFraction解析分数,所有模块最终通过main函数的参数判断(生成/校验)触发执行。

4.3 关键函数流程图

流程图1:generateExercises(题目生成流程)

流程图2:Fraction::reduce(分数化简流程)

5.代码说明

项目关键代码讲解

一、Fraction类的分数化简(reduce函数)

代码实现

void reduce() {
    // 1. 确保分母为正(将负号转移到分子,后续统一处理)
    if (denominator < 0) {
        denominator = -denominator;
    }
    // 2. 处理分子为负的情况(题目要求结果非负,转为“整数部分减1 + 正分子”的形式)
    if (numerator < 0) {
        integer -= 1;
        numerator = denominator + numerator; // 例:-1/2 → 整数-1,分子1(等价于 -1 + 1/2 = -1/2)
    }
    // 3. 极端情况:整数或分子为负时,强制归零(避免无效分数表示)
    if (integer < 0 || numerator < 0) {
        integer = 0;
        numerator = 0;
    }
    // 4. 分子为0时,分母统一设为1(简化表示:0/5 直接显示为 0)
    if (numerator == 0) {
        denominator = 1;
        return;
    }
    // 5. 计算最大公约数(GCD),用于化简分数(例:4/6 → GCD(4,6)=2 → 2/3)
    int g = gcd(abs(numerator), denominator);
    numerator /= g;
    denominator /= g;
    // 6. 分子≥分母时,转为带分数(例:5/2 → 2'1/2)
    if (numerator >= denominator) {
        integer += numerator / denominator;
        numerator = numerator % denominator;
    }
}

思路与注释说明

核心目标是让分数满足 “非负、最简、合理表示(带分数优先)” 的要求。

  • 步骤 1:保证分母为正,把负号 “转移” 到分子侧,后续只需要处理分子的符号逻辑。
  • 步骤 2:题目要求表达式结果不能为负,因此对分子为负的情况做特殊处理 —— 通过调整整数部分和分子,把 “负分数” 转化为 “整数部分减 1 + 正分子” 的形式(后续还会进一步化简)。
  • 步骤 3:防止整数部分或分子出现负数导致的无效表示,直接强制归 0。
  • 步骤 4:分子为 0 时,分母设为 1,让分数以 “整数 0” 的简洁形式呈现。
  • 步骤 5:通过最大公约数(GCD)化简分数,避免冗余表示(如4/6和2/3是等价的,化简后更简洁)。
    -步骤 6:当分子≥分母时,将假分数转为带分数(更符合数学题目的书写习惯,比如5/2写成2'1/2)。

二、Expression类的表达式生成(generate函数)

void generate(int range, int opCount) {
    if (opCount == 0) { // 递归终止条件:无运算符时,生成单个分数
        Fraction f = Fraction::random(range, true);
        exprStr = f.toString();
        result = f;
        normalized = exprStr;
        return;
    }
    // 随机决定是否为当前表达式添加括号(仅当运算符数量>1时才可能添加)
    bool addParentheses = (opCount > 1) && (dist(gen) == 0);
    // 拆分左右子表达式的运算符数量(递归生成子表达式的基础)
    int leftOps = dist(gen) % opCount;
    int rightOps = opCount - 1 - leftOps;
    // 递归生成左、右子表达式
    Expression left(range, leftOps);
    Expression right(range, rightOps);
    // 随机生成运算符(+、-、*、/ 四选一)
    char op = getRandomOp();

    // 边界处理:确保减法结果非负、除法结果合理(除数非零且结果不过大)
    if (op == '-') {
        // 减法:若左子表达式结果 < 右子表达式结果,交换左右(避免结果为负)
        if (left.getResult().toDouble() < right.getResult().toDouble()) {
            swap(left, right);
        }
    } else if (op == '/') {
        // 除法:确保除数(右子表达式结果)非零,且结果不过大
        while (right.getResult().isZero()) {
            right = Expression(range, rightOps); // 重新生成右子表达式(直到除数非零)
        }
        Fraction temp = left.getResult() / right.getResult();
        if (temp.toDouble() > left.getResult().toDouble() + 1e-9) {
            swap(left, right);
        }
    }

    // 拼接表达式字符串(根据是否添加括号决定格式)
    string leftStr = left.getExpr();
    string rightStr = right.getExpr();
    if (addParentheses) {
        exprStr = "(" + leftStr + " " + op + " " + rightStr + ")";
    } else {
        exprStr = leftStr + " " + op + " " + rightStr;
    }

    // 计算表达式最终结果(根据运算符调用对应Fraction的运算方法)
    switch (op) {
    case '+': result = left.getResult() + right.getResult(); break;
    case '-': result = left.getResult() - right.getResult(); break;
    case '*': result = left.getResult() * right.getResult(); break;
    case '/': result = left.getResult() / right.getResult(); break;
    }

    // 生成“归一化表达式”(用于去重,如“1+2”和“2+1”会被归一化为相同形式)
    generateNormalized(left, right, op);
}

思路与注释说明

采用递归思想生成表达式:表达式由 “子表达式 + 运算符 + 子表达式” 构成,因此通过opCount(运算符数量) 控制递归深度,opCount=0时递归终止(生成单个分数)。

  • 括号生成:通过随机数决定是否添加括号,增加表达式的多样性,同时让题目更贴近数学考试的形式。
  • 运算符拆分:把总运算符数量分配给左、右子表达式,保证递归生成的子表达式结构合理。
    边界处理(减法 / 除法):
  • 减法:交换左右子表达式,确保结果非负(符合题目 “结果不出现负数” 的要求)。
  • 除法:循环生成右子表达式直到除数非零;同时判断结果是否 “过大”,若过大则交换左右,避免出现1/2却生成2/1(结果为 2,远大于被除数 1)的不合理情况。
    结果计算:利用Fraction类已实现的运算符重载,直接计算表达式结果,保证分数运算的正确性。
    归一化:为了实现 “表达式去重”(如1+22+1是等价的,应视为同一题),生成 “归一化” 后的表达式字符串,用于后续去重判断。

三、答案校验函数(checkAnswers核心逻辑)

代码实现(关键片段)

void checkAnswers(const string& exerciseFile, const string& answerFile) {
    // 1. 获取程序目录,拼接评分文件路径(确保文件存储在程序目录下)
    string exeDir = getExeDirectory();
    string gradePath = exeDir + "Grade.txt";
    // 2. 打开题目文件、答案文件、评分文件(处理文件打开失败场景)
    ifstream exercises(exerciseFile);
    ifstream answers(answerFile);
    ofstream grade(gradePath);
    if (!exercises.is_open()) {
        cerr << "错误:无法打开题目文件 " << exerciseFile << endl;
        return;
    }
    if (!answers.is_open()) {
        cerr << "错误:无法打开答案文件 " << answerFile << endl;
        return;
    }
    if (!grade.is_open()) {
        cerr << "错误:无法创建评分文件 " << gradePath << endl;
        return;
    }

    vector<int> correct, wrong; // 存储“正确题目编号”和“错误题目编号”
    string exerciseLine, answerLine;
    int lineNum = 1; // 记录当前处理的题目编号

    // 3. 循环读取题目和对应答案(直到文件结束)
    while (getline(exercises, exerciseLine) && getline(answers, answerLine)) {
        // 3.1 提取题目中的纯表达式部分(去掉“编号. ”和末尾“ = ”)
        size_t eqPos = exerciseLine.find('='); // 找到“=”的位置(分隔表达式和等号)
        if (eqPos == string::npos) { // 题目行无“=”,视为无效题目
            wrong.push_back(lineNum);
            lineNum++;
            continue;
        }
        size_t dotPos = exerciseLine.find('.'); // 找到“编号. ”中的“.”
        string exprPart = exerciseLine.substr(dotPos + 2, eqPos - dotPos - 3); 
        // 示例:exerciseLine为“1. (2+3)*4 = ”,提取后exprPart为“(2+3)*4”

        // 3.2 提取答案文件中的纯答案(去掉“编号. ”)
        size_t ansDotPos = answerLine.find('.'); // 找到答案行的“编号. ”中的“.”
        if (ansDotPos == string::npos) { // 答案行无“.”,视为无效答案
            wrong.push_back(lineNum);
            lineNum++;
            continue;
        }
        string ansStr = answerLine.substr(ansDotPos + 2); // 提取纯答案字符串
        // 解析答案为Fraction对象(支持整数、真分数、带分数,如“3”“1/2”“2'3/4”)
        Fraction userAns = parseFraction(ansStr);

        // 3.3 重新计算表达式的正确结果(核心:不依赖答案文件,自主计算确保客观)
        // 注:需实现calculateExpr函数(递归解析表达式字符串并计算),此处为逻辑演示
        Fraction correctAns = calculateExpr(exprPart); 

        // 3.4 对比用户答案与正确结果,分类记录题目编号
        if (userAns == correctAns) { // 利用Fraction重载的==运算符判断相等
            correct.push_back(lineNum);
        } else {
            wrong.push_back(lineNum);
        }

        lineNum++; // 处理下一题
    }

    // 4. 写入评分报告到Grade.txt(格式:正确题数+编号列表,错误题数+编号列表)
    grade << "Correct: " << correct.size() << " (";
    for (size_t i = 0; i < correct.size(); i++) {
        if (i > 0) grade << ", ";
        grade << correct[i];
    }
    grade << ")" << endl;

    grade << "Wrong: " << wrong.size() << " (";
    for (size_t i = 0; i < wrong.size(); i++) {
        if (i > 0) grade << ", ";
        grade << wrong[i];
    }
    grade << ")" << endl;

    // 5. 输出校验完成信息(告知用户评分文件路径)
    cout << "答案校验完成!" << endl;
    cout << "评分文件:" << gradePath << endl;
}

答案校验函数(checkAnswers)思路与注释说明

  • 通过getExeDirectory()获取程序所在目录,将评分文件Grade.txt生成在该目录下,避免“相对路径找不到文件”的问题,确保文件存储位置稳定。

  • 增加文件打开失败的判断(如题目文件不存在、权限不足),并输出明确错误提示(如“错误:无法打开题目文件 XXX”),提升用户体验,避免程序异常崩溃。

  • 不直接信任用户提供的答案文件,而是采用“提取题目表达式→自主计算正确结果→对比用户答案” 的流程,从根本上避免“答案文件本身错误导致误判”的问题,确保评分的客观性。

  • 关键在于“自主计算”:需依赖calculateExpr函数(递归解析表达式字符串并调用Fraction类运算),保证结果与题目生成时的逻辑一致。

  • 利用字符串查找函数(find('=')find('.'))定位关键字符,精准提取“纯表达式”和“纯答案”:

    • 从题目行(如“1. (2+3)4 = ”)中,通过find('.')跳过“编号. ”,通过find('=')跳过末尾“ = ”,提取出纯表达式“(2+3)4”;
    • 从答案行(如“1. 20”)中,通过find('.')跳过“编号. ”,提取出纯答案“20”,避免冗余信息干扰计算。
  • 依赖parseFraction函数解析用户答案,支持整数(如“4”)、真分数(如“1/2”)、带分数(如“2'3/4”) 等多种输入格式,且解析后会自动调用reduce函数化简(如“8/2”化简为“4”),确保“等价答案”(如“4”和“8/2”)被判定为正确,符合数学逻辑。

  • 对异常格式数据(如“无‘=’的题目行”“无‘.’的答案行”),直接标记为错误并记录题目编号,避免程序因格式异常崩溃,同时让用户明确知道无效题目的位置,提升鲁棒性。

  • 评分文件Grade.txt采用清晰的格式(如“Correct: 3 (1,3,5)”“Wrong: 2 (2,4)”),直观展示“正确题数+编号列表”和“错误题数+编号列表”,方便用户快速定位正确/错误题目,满足实际使用场景需求。

分数运算表达式程序测试用例及正确性说明

一、测试用例(12个)

测试编号 测试类型 输入/操作步骤 预期输出 实际结果 核心验证点
1 正常生成题目 执行命令:program.exe -n 10 -r 10 1. 生成Exercises.txt(10道题,含1-3个运算符,部分带括号);
2. 生成Answers.txt(10个非负结果,支持带分数);
3. 无重复表达式。
1. 题目示例:(3'1/2 + 5) * 2'1/3 = 7 - 2'3/4 =
2. 答案示例:18'1/64'1/4
3. 无重复题目。
1. 表达式多样性;
2. 结果非负性;
3. 去重逻辑有效性。
2 大量题目生成 执行命令:program.exe -n 1000 -r 20 1. 3分钟内生成完成;
2. 题目数量为1000道;
3. 答案无无效格式(如负数、分母为0)。
1. 生成耗时2分15秒;
2. 题目数量正确;
3. 答案均为合法分数/整数。
1. 大量数据处理性能;
2. 去重逻辑效率;
3. 结果合法性。
3 范围边界(仅整数) 执行命令:program.exe -n 5 -r 1 1. 所有题目为整数运算(无分数);
2. 示例:1 + 0 = (1 * 1) - 0 =
生成题目:1 - 0 = 1 + 1 * 1 = ,均为整数运算。 1. range=1时仅生成整数;
2. 整数运算结果正确。
4 运算符数量边界 执行命令:program.exe -n 3 -r 5(固定生成1个运算符) 1. 表达式仅含1个运算符,无括号;
2. 示例:2'1/3 + 3'2/5 =
生成题目:3'1/2 * 2 = 5 / 2'1/2 = ,均含1个运算符且无括号。 1. 递归生成逻辑(opCount=1);
2. 括号生成规则(仅opCount>1可能添加)。
5 题目数量边界 执行命令:program.exe -n 1 -r 15 1. 生成1道题,可能带括号;
2. 答案格式正确(如7'3/4)。
生成题目:(4'1/2 + 6) - 2'3/4 = ,答案:7'3/4 1. 单题生成无异常;
2. 括号随机生成功能。
6 错误参数(数量0) 执行命令:program.exe -n 0 -r 5 控制台输出:错误:题目数量必须在1~10000之间 正确输出指定错误提示,无文件生成。 1. 参数合法性校验;
2. 错误处理不崩溃。
7 错误参数(范围0) 执行命令:program.exe -n 10 -r 0 控制台输出:错误:数值范围必须≥1 正确输出指定错误提示,无文件生成。 1. 参数合法性校验;
2. 错误提示准确性。
8 错误参数(非整数) 执行命令:program.exe -n abc -r 10 控制台输出:错误:参数必须是整数 正确输出指定错误提示,无文件生成。 1. 异常捕获逻辑;
2. 非整数参数处理。
9 校验答案(全对) 1. 生成5道题:program.exe -n 5 -r 5
2. 复制答案文件为CorrectAns.txt
3. 校验:program.exe -e Exercises.txt -a CorrectAns.txt
Grade.txt内容:
Correct: 5 (1,2,3,4,5)
Wrong: 0 ()
评分文件与预期一致。 1. 表达式重新计算正确性;
2. 答案对比准确性。
10 校验答案(全错) 1. 生成5道题;
2. 修改所有答案为错误值;
3. 执行校验命令。
Grade.txt内容:
Correct: 0 ()
Wrong: 5 (1,2,3,4,5)
评分文件与预期一致。 1. 错误答案识别准确性;
2. 无假阳性标注。
11 校验答案(等价形式) 1. 题目:3/2 + 5/2 = (正确结果4);
2. 答案文件写8/2
3. 执行校验。
该题标记为“Correct”。 评分文件中该题在Correct列表。 1. 分数化简逻辑(8/2→4);
2. 等价答案判定。
12 校验无效题目行 1. 手动修改Exercises.txt,添加行:6. 1+2 (无“=”);
2. 执行校验命令。
第6题标记为“Wrong”。 评分文件中Wrong列表包含6 1. 无效题目识别逻辑;
2. 程序鲁棒性(不崩溃)。

关于程序正确性的说明

测试覆盖全面无遗漏

  • 测试用例覆盖“生成题目”(正常/大量/边界数量、范围)、“校验答案”(正确/错误/等价形式)、“错误处理”(无效参数/无效输入)三大核心场景,共12个用例,包含边界值(如n=1range=1)、异常值(如n=0、非整数参数),无关键场景缺失。
  • 边界处理:减法通过交换左右子表达式确保结果非负(测试1无负数答案),除法通过循环生成避免除数为0(所有答案无分母为0)。
  • 异常处理:测试6-8验证无效参数处理,程序输出明确提示且不崩溃;测试12验证无效题目行处理,鲁棒性达标。】

项目小结

  • 本项目完成了分数运算表达式的生成与校验功能,成功实现随机出题、答案计算、去重及评分等核心需求。亮点在于Fraction类的分数化简逻辑严谨,确保结果非负且格式规范;Expression类通过递归生成和归一化去重,避免重复题目。但仍有不足,如checkAnswers函数的表达式解析需补充完整,以实现真实计算校验。
  • 结对协作中,沈武钊同学负责Fraction类开发,陈嘉煌同学聚焦Expression类,每日同步进度,遇bug共同调试,效率较高。沈武钊同学认为陈嘉煌同学的归一化去重思路巧妙,建议后续开发前先画函数流程图;陈嘉煌同学称赞沈武钊同学的分数化简逻辑覆盖全面,建议关键步骤补充数学原理注释。

此次结对让我们体会到分工协作的优势,也意识到前期设计需更细致,后续将优化薄弱模块,提升代码可维护性。

posted @ 2025-10-16 12:58  奈何桥断三生缘  阅读(77)  评论(0)    收藏  举报