woshi314

结对项目作业(何星宇、陈广智)

这个作业属于哪个课程 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 数据渲染成火焰图。

性能分析图片展示

优化前的火焰图

flame_before

优化后的火焰图

flame_after

从图中可以直观看到,程序中消耗最大的函数是 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)。

函数间调用关系如下:

graph TD M[main] -->|"生成模式 -n -r"| GE[generate_exercises] M -->|"判题模式 -e -a"| G[grade] GE --> BET[build_expression_tree] BET -->|递归| BET BET --> GO[generate_operand] GO --> EN[ExprNode 叶节点] BET --> EN EN -->|to_str| FF1[format_fraction] EN -->|get_canonical_repr| FF1 G --> PE[parse_expression_to_fraction] G --> PF[parse_fraction]

format_fraction 被题目输出部分与判重部分复用;ExprNode 由生成层构造、被文本化方法使用,此处数据结构的构造和使用是分离的。

关键设计决策

我们原本设计了通过直接拼字符串生成题目的方案,但因以下三个问题,改为表达式树:

  1. 括号处理问题:to_str(parent_op) 在递归输出时比较子节点运算符的优先级与结合性,只在需要处加括号(a - (b - c) 必须保留括号,(a+b)+c 自动化简为 a+b+c);字符串方案则需手写括号配对规则,极易出错。
  2. 判重问题:需求规定"两题能通过有限次交换 +、× 的左右操作数互相变换即算重复"。树结构下只需在 get_canonical_repr 中对 +、× 节点的左右串做字典序排序,即可把 3+(2+1) 与 (1+2)+3 都归一为同一字符串 ((1 + 2) + 3),配合set数据结构,即可实现O(1)水平复杂度的判重;而字符串方案难以判断交换律与左结合的等价关系。
  3. 约束剪枝问题:每个节点缓存子树计算结果 val,"减法不产生负数""除法商为真分数"两条约束在自底向上构树时可以顺便检查,生成与验算同时完成。

另外,我们还决定使用 fractions.Fraction:从操作数生成、中间计算到答案输出不出现任何浮点数,从根上保证 1/6 + 1/8 = 7/24 这类真分数运算的精确性。这是由于我们测试时发现 eval 求值时会退化为浮点除法而误判答案,后来用正则把题面所有数字改写为 Fraction(...) 构造再求值,恢复了精确运算。

关键函数流程图

build_expression_tree 是整个生成过程的核心,其流程如下:

flowchart TD A["build_expression_tree(op_count, r)"] --> B{"op_count == 0 ?"} B -->|"是"| C["generate_operand(r) 生成 [0,r) 内<br>自然数或真分数叶节点"] B -->|"否"| D["随机拆分 left_ops + right_ops = op_count - 1"] D --> E["递归构建左右子树"] E --> F{"子树构建成功?"} F -->|"否"| R F -->|"是"| H["随机选择运算符 + - × ÷"] H --> I{"op == '-' 且 e1 < e2 ?"} I -->|"是"| J["交换左右子树"] I -->|"否"| K J --> K{"op == '÷' ?"} K -->|"是"| L["交换保证 e1 ≤ e2;<br>若 e1==0 或 e1==e2 则重试"] K -->|"否"| M["Fraction 精确计算节点 val<br>返回 ExprNode"] L --> M R["本轮拒绝,循环内重试<br>(≤50 次,耗尽返回 None)"] --> D

图中"交换左右子树"两处,即是效能分析里描述的优化点:约束只依赖两数的大小关系,因此直接交换即可满足条件,无需丢弃整棵子树重新生成。

外层 generate_exercises 的流程如下:

  1. 循环出题
  2. 每题取规范串查询 seen_canonical 集合,重复则放弃方案
  3. 采纳则把 to_str() 题面与 format_fraction(val) 答案同步写入两个文件
  4. 达到 n 题或超过 n×200 次尝试后停止
  5. 若 r 过小导致题目空间不足,打印实际生成数量。

判题模块的数据流

grade() 按行读取题目与答案文件并逐行配对,流程如下:

以"3. 2'3/8 × 1/2 ="为例

  1. 去掉编号与等号,得到 "2'3/8 × 1/2"
  2. 采用正则,将数字改写为 Fraction ,得到"(Fraction(2)+Fraction(3,8))*Fraction(1,2)"
  3. 受限 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})

思路说明:

  1. 正则的分支按"带分数→分数→整数"的次序书写,利用"或"分支从左到右优先匹配的特性,一次扫描完成分类。
  2. 替换后表达式内所有数都是 Fraction,Python 的 Fraction 四则运算封闭,且会自动约分,+ - * / 对应原题面的运算符;
  3. 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,可以自动测试用例,反复检测正确性。

六、项目小结

分工回顾

本次结对,何星宇负责初版代码的架构设计与实现;陈广智负责测试验证、缺陷修复与性能优化。博客由各章节负责人分别撰写后互审合稿。

成败得失

成功之处:初版把判重和括号最小化这两个较为复杂的部分一次设计到位,后续所有改动都没有修改这两处,这说明选择好的数据结构至关重要;改进阶段也较为严谨:先用火焰图定位耗时,发现其实是被拒绝采样浪费的重试,再针对"约束只依赖大小关系"这一特点,修改为交换构造,得到了具体的优化参数。

经验教训:

  1. 没有报错的情况下仍会出现Bug。判题模块把自己的标准答案判错了 7/10,却没有任何报错,而审查方用程序自己的输出进行检查才发现了缺陷。这教会我们把"自洽性检查"当成最低成本的回归手段。
  2. 边界值必须仔细分析。-r 2 是需求允许的合法输入,却触发了 randint(2, 1) 的崩溃。"参数可以设置为 1 或其他自然数",题目的要求中隐藏了两个边界条件,读需求时要仔细分析。
  3. 工具链意识。浮点误差、随机数函数的空区间参数两个问题,在单步执行中无法发现、在大样本测试中才能发现。
  4. 时间规划存在一定误判。PSP 预估明显低估了编码环节,问题主要出在低估了分数精度处理和判重细节的复杂程度,应该留出更多的调试时间。

结对感受与互相评价

何星宇对陈广智的评价:

他的闪光点是:在发现程序误判题目时,会直接定位根本问题。例如面对"除法计算退化到浮点数"这个问题,他没有用简单的 if 判断打补丁,而是把所有数值计算统一到 Fraction,从根本上解决了问题。我的建议是:希望在改动前能先同步一下清单,哪些是必须修的 Bug、哪些是可做的优化,分好了再继续处理,复审时会更省力。

陈广智对何星宇的评价:

我认为他的闪光点是:对题目的理解很完整,详细认真地分析了题目的需求,初次提交的代码完成度便很高。而我对他的建议是:下次开发完成后,可以画一些简单的流程图,附上大体的说明。虽然他的代码结构已经足够清晰易懂,但少量说明便可以进一步地提高审查效率。

共同收获:结对编程不仅提高了我们的开发速度,而且让我们学会参考他人的视角进行开发。我们一致认可的改进计划是:下一次协作时,测试文件与功能代码可以同步开发。

posted on 2026-09-22 15:52  woshi314  阅读(8)  评论(0)    收藏  举报

导航