结对项目作业(何星宇、陈广智)
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703 |
| 这个作业的目标 | 实现一个自动生成小学四则运算题目的命令行程序,通过双人结对合作的方式,积累项目合作经验。 |
姓名与学号:何星宇(3124004467),陈广智(3124007003)
项目地址:https://github.com/woshi314/Software-Engineering_Pair-Project
一、PSP2.1表格
开始项目之前,我们两人一同预估了本次项目各个部分的耗时,项目完成后记录了实际耗时。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 35 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 35 |
| Development | 开发 | 395 | 490 |
| · Analysis | · 需求分析 (包括学习新技术) | 50 | 55 |
| · Design Spec | · 生成设计文档 | 40 | 40 |
| · Design Review | · 设计复审 (和同事审核设计文档) | 25 | 25 |
| · Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 15 | 15 |
| · Design | · 具体设计 | 50 | 60 |
| · Coding | · 具体编码 | 100 | 145 |
| · Code Review | · 代码复审 | 45 | 60 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 70 | 90 |
| Reporting | 报告 | 85 | 95 |
| · Test Report | · 测试报告 | 35 | 50 |
| · Size Measurement | · 计算工作量 | 15 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 35 | 35 |
| 合计 | 510 | 630 |
二、效能分析
本次效能分析,我们约耗时50分钟左右。
我们用 cProfile 对性能做了定量分析,即一次性生成一万个题目,再用 flameprof 把 profile 数据渲染成火焰图。
性能分析图片展示
优化前的火焰图

优化后的火焰图

从图中可以直观看到,程序中消耗最大的函数是 build_expression_tree,其下挂着 generate_operand 和整个 random 模块,random 相关调用在图中占了最宽的色块。对应的文字报告:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 总函数调用次数 | 3,049,762 | 2,116,744 | −31% |
| cProfile 总耗时 | 0.883 s | 0.611 s | −31% |
| build_expression_tree 累计耗时 | 0.649 s(占 73%) | 0.402 s | −38% |
| build_expression_tree 调用次数 | 78,566 | 52,559 | −33% |
| random.randint 调用次数 | 129,060 | 82,072 | −36% |
| 生成 10000 题实际耗时 | 0.30 s | 0.20 s | −33% |
程序中消耗最大的函数
build_expression_tree 被外部调用,请求生成题目的次数为 10402;而build_expression_tree递归调用自身,以构建左右子树的次数为 78566 。
根据分析,每道题的表达式最多3个运算符,理想情况下只需构建4~7个节点,实际上却执行了 78566 ÷ 10402 = 7.5 次的节点构建,存在一定的优化空间。
出现这种情况的原因,是在出现 "减法产生负数"或"除法结果不是真分数" 的情况时,就会把整棵子树丢弃重来,许多生成的尝试都被拒绝,浪费了性能。
改进思路
对于这两条约束,实际上不必在条件不满足时直接抛弃结果,而是可以做简单处理,使结果可用。
出现"减法产生负数"情况时(即 e1 < e2):随机生成两个操作数后,若不满足就交换左右子树,保证结果被接受;
出现"除法商为真分数"情况时,(即0 < e1/e2 < 1):先交换保证 e1 ≤ e2,此时商必然 ≤ 1,随后便只剩 0÷x、a÷a 两类极端情况需要重新采样,实测节点构建次数下降约三分之一。
# 规则 3:计算过程不能产生负数 (e1 >= e2)——交换两操作数即可满足
if op == "-" and left_node.val < right_node.val:
left_node, right_node = right_node, left_node
# 规则 4:e1 ÷ e2 的结果必须是真分数——交换保证 e1 <= e2,仅相等/为零时重试
if op == "÷":
if left_node.val > right_node.val:
left_node, right_node = right_node, left_node
if left_node.val == 0 or left_node.val == right_node.val:
continue
改进后重跑 cProfile,各项指标下降约 31%,从火焰图中 random 色块变窄可以直观地看出性能优化效果。同时我们用回归测试确认了约束语义没有被破坏:对 r=2/3/10/20 各抽 300 题,逐节点验证是否"减法非负、除法商为真分数、操作数在范围内",全部满足约束条件。
三、设计实现过程
代码总体组织
程序为单文件 Myapp.py,按职责划分为5个层级。
| 层 | 组成 | 职责 |
|---|---|---|
| 命令行入口层 | main() | argparse 解析参数、校验,分发到"生成"或"判题"两种模式 |
| 数据表示层 | ExprNode 类、format_fraction()、parse_fraction() | 表达式树的节点结构;分数与 2'3/8 文本格式互转 |
| 题目生成层 | generate_exercises() → build_expression_tree() → generate_operand() | 随机出题、约束检查、去重、写出题目与答案文件 |
| 判题层 | grade() → parse_expression_to_fraction() | 读入题目/答案文件,题面求值、比对、统计写出 Grade.txt |
| 持久化 | Exercises.txt / Answers.txt / Grade.txt | 三个输出文件,编号逐行对应 |
分层原则是"生成与判题只通过文件交换数据":生成端把表达式树写为文本,判题端把文本还原为数值,两侧共用同一套分数格式化函数,保证格式对称。
类与函数的数量及关系
本项目有1个类和8个函数。ExprNode 是唯一的类,持有四个成员:val(该子树的精确计算结果,Fraction 类型)、op(运算符,叶节点为 None)、left/right(左右子树),两个方法分别负责"输出题面文本"(to_str)和"生成判重规范串"(get_canonical_repr)。
函数间调用关系如下:
format_fraction 被题目输出部分与判重部分复用;ExprNode 由生成层构造、被文本化方法使用,此处数据结构的构造和使用是分离的。
关键设计决策
我们原本设计了通过直接拼字符串生成题目的方案,但因以下三个问题,改为表达式树:
- 括号处理问题:to_str(parent_op) 在递归输出时比较子节点运算符的优先级与结合性,只在需要处加括号(a - (b - c) 必须保留括号,(a+b)+c 自动化简为 a+b+c);字符串方案则需手写括号配对规则,极易出错。
- 判重问题:需求规定"两题能通过有限次交换 +、× 的左右操作数互相变换即算重复"。树结构下只需在 get_canonical_repr 中对 +、× 节点的左右串做字典序排序,即可把 3+(2+1) 与 (1+2)+3 都归一为同一字符串 ((1 + 2) + 3),配合set数据结构,即可实现O(1)水平复杂度的判重;而字符串方案难以判断交换律与左结合的等价关系。
- 约束剪枝问题:每个节点缓存子树计算结果 val,"减法不产生负数""除法商为真分数"两条约束在自底向上构树时可以顺便检查,生成与验算同时完成。
另外,我们还决定使用 fractions.Fraction:从操作数生成、中间计算到答案输出不出现任何浮点数,从根上保证 1/6 + 1/8 = 7/24 这类真分数运算的精确性。这是由于我们测试时发现 eval 求值时会退化为浮点除法而误判答案,后来用正则把题面所有数字改写为 Fraction(...) 构造再求值,恢复了精确运算。
关键函数流程图
build_expression_tree 是整个生成过程的核心,其流程如下:
图中"交换左右子树"两处,即是效能分析里描述的优化点:约束只依赖两数的大小关系,因此直接交换即可满足条件,无需丢弃整棵子树重新生成。
外层 generate_exercises 的流程如下:
- 循环出题
- 每题取规范串查询 seen_canonical 集合,重复则放弃方案
- 采纳则把 to_str() 题面与 format_fraction(val) 答案同步写入两个文件
- 达到 n 题或超过 n×200 次尝试后停止
- 若 r 过小导致题目空间不足,打印实际生成数量。
判题模块的数据流
grade() 按行读取题目与答案文件并逐行配对,流程如下:
以"3. 2'3/8 × 1/2 ="为例
- 去掉编号与等号,得到 "2'3/8 × 1/2"
- 采用正则,将数字改写为 Fraction ,得到"(Fraction(2)+Fraction(3,8))*Fraction(1,2)"
- 受限 eval 求值,得到 Fraction(19,16),与 parse_fraction("1'3/16") 进行比对
比对结果按题号归入 Correct/Wrong 两个列表,最终按 Correct: m (id, id, …) 的规范格式写出 Grade.txt;求值失败一律计入 Wrong。
四、代码说明
本节选取四段关键代码,解释实现思路与注释。
format_fraction / parse_fraction:分数格式化可逆
真分数 3/5、带分数 2'3/8 的题目文本格式封装为一对可逆的函数,生成端与判题端共同使用,保证格式对称:
def format_fraction(frac: Fraction) -> str:
"""将 Fraction 对象转化为带分数/真分数/整数格式:例如 2'3/8, 3/5, 0, 4"""
if frac.denominator == 1:
return str(frac.numerator) # 整数:4
whole = frac.numerator // frac.denominator
rem = frac.numerator % frac.denominator
if whole > 0:
return f"{whole}'{rem}/{frac.denominator}" # 带分数:2'3/8
return f"{rem}/{frac.denominator}" # 真分数:3/5
思路说明:Fraction 本身是自动约分的假分数表示,输出时只需拆出整数部分和余数部分。由于 Fraction 保证分子分母互质,rem/denominator 必为最简真分数,无需额外约分。parse_fraction 则按撇号 '、斜杠 / 的出现情况分三路还原为 Fraction 对象。
ExprNode.to_str:括号生成
题目文本要求"只在数学上必要时加括号",直接对字符串做规则匹配很难写对,而表达式树上只需比较父子运算符的优先级:
def to_str(self, parent_op=None) -> str:
"""生成带括号的表达式文本"""
if self.op is None:
return format_fraction(self.val) # 叶节点:直接输出分数
op_precedence = {"+": 1, "-": 1, "×": 2, "÷": 2}
my_prec = op_precedence[self.op]
left_str = self.left.to_str(self.op)
right_str = self.right.to_str(self.op)
# 右子树:优先级更低,或同级但本节点是 '-'/'÷'(不满足右结合)时必须加括号
if self.right.op and (
op_precedence[self.right.op] < my_prec
or (self.op in ("-", "÷") and op_precedence[self.right.op] == my_prec)
):
right_str = f"({right_str})"
# 左子树:只有优先级更低才需要括号(同级靠左结合不需要括号)
if self.left.op and op_precedence[self.left.op] < my_prec:
left_str = f"({left_str})"
return f"{left_str} {self.op} {right_str}"
思路说明:子树优先级低于当前运算符才可能改变运算顺序。但a-(b-c)、a÷(b÷c) 是特例,去除括号后运算符会改变,因此必须保留括号;而 (a-b)-c 可省略左括号,对应代码里左子树只在"严格更低"时加括号。递归传参 parent_op ,让每个节点知道"父亲是谁",自顶向下传递约束。
get_canonical_repr:规范化字符串以方便判重
需求 6 规定重复的判定标准是"有限次交换 +、× 的左右操作数",我们的解法是为每棵树计算一个"规范形",把等价类压缩成唯一字符串:
def get_canonical_repr(self) -> str:
"""计算规范化字符串用于判重(利用加法与乘法的可交换性)"""
if self.op is None:
return format_fraction(self.val)
c_left = self.left.get_canonical_repr()
c_right = self.right.get_canonical_repr()
# 加法与乘法遵循交换律:左右规范串按字典序固定排序
if self.op in ("+", "×"):
if c_left > c_right:
c_left, c_right = c_right, c_left
return f"({c_left} {self.op} {c_right})"
思路说明:对满足交换律的 +、×,将左右子树的规范串按字典序排序后再拼接,无论原树如何交换,产出的字符串都一样;而 -、÷ 不满足交换律,保持原序。全括号格式消除了优先级歧义。
举例:1+2+3(左结合即 (1+2)+3)与 3+(2+1) 都归一为 ((1 + 2) + 3) 判为重复,而 3+2+1 归一为 ((2 + 3) + 1) 判为不重复。规范串存入 set,利用该数据结构哈希的特性,将判重的时间复杂度从两两比较的 O(n²) 降为 O(1)。
parse_expression_to_fraction:精确求值以解决浮点问题
题面是文本,求值若直接使用 eval,1/9 等除法会退化为二进制浮点数,与通过 Fraction 得到的标准答案对比会出错。
# 数字 token 匹配:带分数 2'3/8 | 分数 3/8 | 整数 2(一次扫描按次序优先匹配)
_NUM_TOKEN_RE = re.compile(r"(\d+)'(\d+)/(\d+)|(\d+)/(\d+)|(\d+)")
def parse_expression_to_fraction(expr_str: str) -> Fraction:
"""将题目的算术表达式字符串求值,采用 Fraction 以避免浮点误差"""
expr = (expr_str.replace("×", "*").replace("÷", "/")
.replace("−", "-").replace("’", "'")) # 兼容全角符号
# 把所有数字统一替换为 Fraction 构造:
# 2'3/8 -> (Fraction(2)+Fraction(3,8)),3/8 -> Fraction(3,8),2 -> Fraction(2)
def _repl(m: re.Match) -> str:
if m.group(1) is not None:
return f"(Fraction({m.group(1)})+Fraction({m.group(2)},{m.group(3)}))"
if m.group(4) is not None:
return f"Fraction({m.group(4)},{m.group(5)})"
return f"Fraction({m.group(6)})"
expr_eval_str = _NUM_TOKEN_RE.sub(_repl, expr)
# 受限作用域 eval:屏蔽内置函数,仅暴露 Fraction
return eval(expr_eval_str, {"__builtins__": None, "Fraction": Fraction})
思路说明:
- 正则的分支按"带分数→分数→整数"的次序书写,利用"或"分支从左到右优先匹配的特性,一次扫描完成分类。
- 替换后表达式内所有数都是 Fraction,Python 的 Fraction 四则运算封闭,且会自动约分,+ - * / 对应原题面的运算符;
- eval 传入 {"builtins": None} 的限制,只能做算术运算,防止题目文件中的恶意内容执行任意代码。该函数与生成端共用 parse_fraction/format_fraction 。
五、测试运行
测试分为可重复执行的 test_regression.py,以及针对命令行行为的用例。共 13 个用例,均已在最终版本上实际执行并通过。
测试用例清单
| # | 用例 | 输入 / 操作 | 实测结果 | 覆盖需求 |
|---|---|---|---|---|
| 1 | 基本生成 | -n 10 -r 10 | 生成 10 行 序号. 表达式 =,题目与答案数量一致 | 1、7 |
| 2 | 缺少必填参数 | 无参数直接运行 | 输出错误提示与帮助信息,退出码 1 | 2 |
| 3 | r=1 边界 | -n 5 -r 1 | 正常运行,仅生成自然数题目 | 2 |
| 4 | r=2 边界(回归) | -n 20 -r 2 | 正常运行;修复前此处必现 ValueError | 2 |
| 5 | 万题规模 | -n 10000 -r 20 | 全部生成,耗时约 0.2s | 9 |
| 6 | 交换律判重 | 对 (1+2)+3、3+(2+1)、(3+2)+1 取规范串 | 前两者相同判为重复,第三者判为不重复,与需求示例一致 | 6 |
| 7 | 减法非负 | r=2/3/10/20 各 300 题,逐节点断言 e1 ≥ e2 | 1200 题全部满足 | 3 |
| 8 | 除法商为真分数 | 同样本逐节点断言 0 < e1÷e2 < 1 | 全部满足 | 4 |
| 9 | 数值范围与运算符数 | 同样本逐叶断言操作数 ∈ [0, r),运算符数 ∈ [1, 3] | 全部满足 | 2、5 |
| 10 | 端到端自洽 | 生成 500 题后判题其自身答案 | Correct: 500, Wrong: 0(修复前 10 题仅判对 2~3 题) | 7、8、10 |
| 11 | 精确求值单测 | 1/6+1/8、3+3'1/9+3'1/3 等 4 例 | 结果与精确 Fraction 期望值一致 | 7 |
| 12 | 答错统计 | 手工 3 题,其中 1 题故意答错 | Correct: 2 (1, 2) / Wrong: 1 (3),编号准确 | 10 |
| 13 | 输入文件缺失 | 指定不存在的题目/答案文件 | 输出读取失败提示后正常退出,不崩溃 | 健壮性 |
用例 4、10 对应两个已修复 Bug 的回归测试,分别固化边界取值与判题自洽性质;提交历史亦按此划分,927307d 为性能优化,9c3546a 为 Bug 修复与本节测试。
正确性的判定依据
我们对需求1~10,每条分别至少有一个用例验证;出题部分和验题部分相互对称,也可以说明设计的正确性。并且通过test_regression.py,可以自动测试用例,反复检测正确性。
六、项目小结
分工回顾
本次结对,何星宇负责初版代码的架构设计与实现;陈广智负责测试验证、缺陷修复与性能优化。博客由各章节负责人分别撰写后互审合稿。
成败得失
成功之处:初版把判重和括号最小化这两个较为复杂的部分一次设计到位,后续所有改动都没有修改这两处,这说明选择好的数据结构至关重要;改进阶段也较为严谨:先用火焰图定位耗时,发现其实是被拒绝采样浪费的重试,再针对"约束只依赖大小关系"这一特点,修改为交换构造,得到了具体的优化参数。
经验教训:
- 没有报错的情况下仍会出现Bug。判题模块把自己的标准答案判错了 7/10,却没有任何报错,而审查方用程序自己的输出进行检查才发现了缺陷。这教会我们把"自洽性检查"当成最低成本的回归手段。
- 边界值必须仔细分析。-r 2 是需求允许的合法输入,却触发了 randint(2, 1) 的崩溃。"参数可以设置为 1 或其他自然数",题目的要求中隐藏了两个边界条件,读需求时要仔细分析。
- 工具链意识。浮点误差、随机数函数的空区间参数两个问题,在单步执行中无法发现、在大样本测试中才能发现。
- 时间规划存在一定误判。PSP 预估明显低估了编码环节,问题主要出在低估了分数精度处理和判重细节的复杂程度,应该留出更多的调试时间。
结对感受与互相评价
何星宇对陈广智的评价:
他的闪光点是:在发现程序误判题目时,会直接定位根本问题。例如面对"除法计算退化到浮点数"这个问题,他没有用简单的 if 判断打补丁,而是把所有数值计算统一到 Fraction,从根本上解决了问题。我的建议是:希望在改动前能先同步一下清单,哪些是必须修的 Bug、哪些是可做的优化,分好了再继续处理,复审时会更省力。
陈广智对何星宇的评价:
我认为他的闪光点是:对题目的理解很完整,详细认真地分析了题目的需求,初次提交的代码完成度便很高。而我对他的建议是:下次开发完成后,可以画一些简单的流程图,附上大体的说明。虽然他的代码结构已经足够清晰易懂,但少量说明便可以进一步地提高审查效率。
共同收获:结对编程不仅提高了我们的开发速度,而且让我们学会参考他人的视角进行开发。我们一致认可的改进计划是:下一次协作时,测试文件与功能代码可以同步开发。
浙公网安备 33010602011771号