简介:这是一份用Python编写的随机出题小工具,面向想给小朋友练习加减乘除的家长或编程初学者,通过运行脚本即可自动生成口算题目,省去手动出题的麻烦。压缩包共352个文件,以288个py源码文件为主,另含25个pyd、11个exe、6个dll等运行依赖,以及少量txt、xml、cfg、bat配置与说明文件,整体约8.32MB,属于可直接运行的完整项目环境。目前已有9049人学习下载,热度较高。资源亮点在于出题逻辑清晰,适合边玩边学;作者还特别提示了Python中true除法与floor除法同数学除法的差异,遇到除不尽的情况可引导孩子用分数表示或口述答案,并借此讲解编程与数学的区别。读者可借此理解随机数生成、运算符选择、循环与输入判断等基础用法,也能直接拿来当亲子练习工具,或在此基础上改造成闯关、计时、错题记录等玩法。
1. 随机出题器:为什么你写的random.randint总被家长一眼看穿
用 Python 实现随机出题(加减乘除),听起来像是编程入门第一课的水平,但我见过太多版本在真实场景里活不过三分钟。某位家长给孩子用了一个网上抄来的出题脚本,第一天孩子做了 50 道题,第二天发现题目和昨天一模一样,第三天直接不做了——因为脚本每次启动都用固定种子,或者更常见的,题目池子太小,两位数加减法翻来覆去就那几十种组合。随机出题的核心矛盾从来不是「能不能随机」,而是「随机得有没有教学价值」:除法不能出不尽,减法不能出负数,乘法不能超纲,加减乘除混合时运算优先级不能让孩子崩溃。这篇文章面向的是想给自己孩子、学生或培训班做一套可持续使用的口算练习工具的开发者,我会从出题引擎的约束设计讲到批量生成 PDF 的完整链路,中间穿插我踩过的血泪坑,比如浮点数精度导致除法判错、题目去重时集合把字典吃掉这类翻车现场。读完你能拿到一套可直接跑的代码,也能明白每个参数为什么这么设。
2. 出题引擎的约束设计:让随机数服从教学逻辑
2.1 四则运算各自的合法域与边界条件
纯随机生成a + b很容易,但教学场景下每个运算符都有隐含约束。加法通常要求结果不超过某个上限(比如 100 以内),减法要求被减数大于等于减数,乘法要求两个因数都在乘法表范围内(中文语境下一般是 1~9 或 1~12),除法要求被除数能被除数整除且除数不为零。这些约束如果不在生成阶段处理,就得在渲染阶段过滤,而过滤会导致题目数量不可控——你设了 50 道,实际输出可能只有 32 道。
我一般把每个运算符的约束写成一个独立的校验函数,生成时循环重试直到满足条件。重试次数要设上限,否则遇到极端参数(比如要求 100 以内减法且结果必须大于 90)会死循环。下面是一个可复用的约束校验模块:
import random def validate_add(a, b, max_result=100): """加法:结果不超过上限,且两个加数都为正整数""" return a > 0 and b > 0 and (a + b) <= max_result def validate_sub(a, b, allow_negative=False): """减法:默认不允许负数结果""" if allow_negative: return a >= 0 and b >= 0 return a >= b and b >= 0 def validate_mul(a, b, max_factor=9): """乘法:因数在乘法表范围内""" return 1 <= a <= max_factor and 1 <= b <= max_factor def validate_div(a, b, max_quotient=9): """除法:整除、除数非零、商在范围内""" if b == 0: return False return a % b == 0 and 1 <= (a // b) <= max_quotient这段代码的关键在于把「合法性」和「生成」分离。validate_div里先判b == 0再取模,顺序不能反,否则 ZeroDivisionError 直接让脚本崩掉。max_quotient控制商的上限,避免出现 81 ÷ 1 = 81 这种虽然整除但超纲的题。参数max_factor默认 9 对应中文乘法表,如果做英文数学练习可以调到 12。
2.2 用重试+洗牌替代纯随机:避免题目扎堆
直接random.randint生成 50 道题,大概率会出现连续多道3 + 5这种重复。更隐蔽的问题是分布不均:加法题里结果小于 10 的会占很大比例,因为小数字组合多。解决办法是先生成一个候选池,再从中采样。
我的做法是:对每个运算符,枚举所有合法组合存入列表,然后用random.sample抽取。这样既保证不重复,又保证分布均匀。候选池的大小要提前估算——100 以内加法大约有 5000 种组合,乘法表只有 81 种,除法(商≤9)大约 200 种。如果要求的题目数量超过池子大小,就得允许重复或放宽约束。
def build_pool(op, count, **kwargs): """构建候选池并采样,count 为需要的题目数""" pool = [] if op == '+': for a in range(1, 100): for b in range(1, 100): if validate_add(a, b, kwargs.get('max_result', 100)): pool.append((a, '+', b)) elif op == '-': for a in range(1, 100): for b in range(1, a + 1): if validate_sub(a, b): pool.append((a, '-', b)) elif op == '*': mf = kwargs.get('max_factor', 9) for a in range(1, mf + 1): for b in range(1, mf + 1): pool.append((a, '*', b)) elif op == '/': mq = kwargs.get('max_quotient', 9) for b in range(1, mq + 1): for q in range(1, mq + 1): pool.append((b * q, '/', b)) if len(pool) < count: # 池子不够就允许重复采样 return random.choices(pool, k=count) return random.sample(pool, count)random.choices和random.sample的区别要记牢:前者允许重复,后者不允许。当池子不够时用choices兜底,避免程序报错。build_pool里除法的构造方式比较绕——先枚举除数和商,再乘出被除数,这样保证整除。如果反过来枚举被除数和除数再判断整除,循环次数会多很多。
2.3 混合运算的优先级处理与括号策略
当题目从单一运算符升级到加减乘除混合时,运算优先级就成了必须处理的问题。3 + 5 * 2和(3 + 5) * 2结果不同,如果随机拼接运算符而不加括号,孩子算出来的答案可能和程序预期不一致。常见做法有两种:一是只生成同级运算(全是加减或全是乘除),二是生成混合运算但强制加括号明确优先级。
我倾向于对小学低年级只出同级运算,高年级再引入混合。如果要做混合,用eval验证答案时要注意安全问题——eval会执行任意代码,虽然这里输入可控,但养成习惯用ast.literal_eval或自己写解析器更好。实际项目中我直接用一个简单的栈式求值函数,避免引入eval这个黑匣子。
def safe_eval(expr): """仅支持 + - * / 和括号的极简求值,避免 eval 的安全隐患""" # 将表达式转为 Python 可求值形式后限制命名空间 allowed = {"__builtins__": None} try: result = eval(expr, allowed, {}) return result except Exception: return None把__builtins__设为None能挡住大部分危险调用,但这不是绝对安全的方案。如果题目表达式完全由你自己的代码生成,风险可控;如果允许用户输入表达式,必须换用ast模块做语法树校验。参数allowed里还可以加入abs、round等白名单函数,但四则运算用不上。
3. 从题目列表到可打印试卷:排版、去重与答案分离
3.1 用 reportlab 生成 PDF 试卷的最小可用配置
出题引擎跑通后,下一步是把题目列表变成能打印的试卷。纯文本输出太简陋,家长需要的是带题号、留白、分栏的 PDF。Python 生态里生成 PDF 的库不少,reportlab是最成熟的选择之一,安装命令是pip install reportlab。下面是一个生成 A4 试卷的最小示例:
from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import mm def generate_pdf(questions, filename="worksheet.pdf", cols=2): """questions 为 (a, op, b) 列表,cols 控制分栏数""" c = canvas.Canvas(filename, pagesize=A4) width, height = A4 margin = 20 * mm col_width = (width - 2 * margin) / cols line_height = 12 * mm y = height - margin col = 0 for idx, (a, op, b) in enumerate(questions, 1): x = margin + col * col_width c.drawString(x, y, f"{idx}. {a} {op} {b} = ______") y -= line_height if y < margin: col += 1 y = height - margin if col >= cols: c.showPage() col = 0 y = height - margin c.save()A4的尺寸是 595×842 点,mm是毫米转点的系数。margin设 20mm 是打印安全边距,低于 10mm 可能被打印机裁掉。line_height控制行距,12mm 大约能写下一行算式加答案。分栏逻辑里col和y的复位容易写错——每次换页要把col归零、y重置到顶部,否则第二页会从中间开始。drawString的坐标原点在左下角,所以y是递减的。
3.2 题目去重:为什么 set 会吃掉你的题目顺序
去重听起来简单,list(set(questions))一行搞定,但 set 是无序的,去重后题目顺序全乱,而且如果题目用字典表示(比如{"a": 3, "op": "+", "b": 5}),字典不可哈希,set 直接报 TypeError。这是新手最常见的翻车点之一。
正确做法是用有序去重:遍历列表,用辅助 set 记录已见过的题目,保留首次出现的顺序。
def dedupe_ordered(questions): """保持顺序的去重,questions 为元组列表""" seen = set() result = [] for q in questions: if q not in seen: seen.add(q) result.append(q) return result如果题目是字典,先转成元组(a, op, b)再去重,输出时再转回字典。seen集合的查找是 O(1),整体复杂度 O(n),比双重循环快得多。注意元组里如果包含列表(比如选项列表),同样不可哈希,需要转成字符串或冻结集合。
3.3 答案页分离与题号对齐的细节
试卷和答案必须分开打印,否则孩子直接抄答案。答案页的题号要和试卷严格对齐,否则家长批改时找不到对应题。我的做法是生成题目时同时保留原始索引,答案页按相同顺序输出。
def generate_answer_pdf(questions, filename="answers.pdf"): """答案页,每行显示题号和答案""" c = canvas.Canvas(filename, pagesize=A4) width, height = A4 y = height - 20 * mm for idx, (a, op, b) in enumerate(questions, 1): ans = eval(f"{a} {op} {b}") c.drawString(20 * mm, y, f"{idx}. {a} {op} {b} = {ans}") y -= 8 * mm if y < 20 * mm: c.showPage() y = height - 20 * mm c.save()答案页的行距可以比试卷小,8mm 足够。eval在这里只用于计算已知合法的表达式,风险可控,但如果你介意,可以换成operator模块的add、sub、mul、truediv映射。题号从 1 开始递增,和试卷的enumerate(questions, 1)保持一致。如果试卷分栏后题号是横向排列的,答案页也要用相同的排列逻辑,否则家长对不上号。
4. 避坑与排查:那些让出题器翻车的细节
4.1 除法结果浮点数精度导致判错
现象:6 / 3在 Python 里结果是2.0,和整数2比较时2.0 == 2为 True,但如果你用is比较或者把结果转成字符串再比对,就会出问题。更隐蔽的是0.1 + 0.2 != 0.3这类浮点误差,在生成小数运算题时会让答案校验失败。
原因:IEEE 754 浮点数的二进制表示无法精确表达某些十进制小数。
解决:除法题强制用整数运算,判断整除用a % b == 0,商用a // b。如果必须出小数题,用decimal.Decimal或fractions.Fraction,比较时设一个容差abs(x - y) < 1e-9。
4.2 随机种子固定导致每次题目相同
现象:脚本每次运行生成的题目完全一样,孩子做第二遍时直接背答案。
原因:代码里写了random.seed(42)或者某个固定值,或者用了random.Random(0)实例。
解决:去掉固定种子,或者用当前时间戳random.seed()不传参。如果做「同一套题重复打印」的功能,把种子存下来,下次用同一种子复现,但默认模式必须随机。
4.3 题目数量与池子大小不匹配导致死循环
现象:设置生成 200 道乘法题,程序卡住不动。
原因:乘法表 1~9 只有 81 种组合,random.sample要求样本数不超过池子大小,超过就抛 ValueError;如果用 while 循环重试,就变成死循环。
解决:生成前检查len(pool) >= count,不够就放宽约束(比如乘法因数扩到 12)或允许重复。用random.choices替代random.sample可以避免报错,但要去重的话得另想办法。
4.4 PDF 中文乱码与字体缺失
现象:试卷里的中文标题显示成方块或问号。
原因:reportlab默认字体不支持中文,需要注册中文字体。
解决:用pdfmetrics.registerFont(TTFont('SimSun', 'simsun.ttf'))注册系统字体,然后c.setFont('SimSun', 12)。字体文件路径要写对,Linux 服务器上可能没有中文字体,需要提前安装或打包字体文件。如果只是数字和符号,用默认的 Helvetica 就够了,不必折腾字体。
4.5 运算符优先级导致答案与预期不符
现象:混合运算题2 + 3 * 4程序算出来是 14,但你想让孩子先算加法得到 20。
原因:Python 的eval遵循标准优先级,乘法先于加法。
解决:如果要强制从左到右计算,必须加括号(2 + 3) * 4,或者自己写一个从左到右的求值器。出题时明确标注「先乘除后加减」或「从左到右」,不要让孩子猜规则。
5. 进阶技巧:用模板与配置文件驱动批量出题
走到这里,你已经有一套能跑的出题器了。但如果每次换题型都要改代码,维护成本会很高。我的习惯是把题目配置抽成 JSON 或 YAML,用模板驱动生成。比如下面这个配置定义了三套试卷:
{ "worksheets": [ { "title": "一年级口算·20以内加法", "ops": ["+"], "count": 50, "max_result": 20, "cols": 2 }, { "title": "二年级口算·乘法表", "ops": ["*"], "count": 60, "max_factor": 9, "cols": 3 }, { "title": "三年级混合·加减乘除", "ops": ["+", "-", "*", "/"], "count": 40, "max_result": 100, "max_factor": 9, "max_quotient": 9, "cols": 2 } ] }读取配置后循环调用前面的build_pool和generate_pdf,就能批量产出不同难度的试卷。ops列表里如果包含多个运算符,需要按比例分配题目数量,比如加减乘除各 10 道,或者按权重随机抽取。我一般用random.choices(ops, weights=...)控制各运算符的出现频率,避免除法题过多让孩子崩溃。
验证生成结果是否合理,我常用两个指标:一是题目重复率,用len(set(questions)) / len(questions)计算,低于 0.9 就说明池子太小或采样有问题;二是答案分布,把答案画成直方图,如果某个数值占比超过 20%,说明分布偏斜,需要调整约束。这两个检查用collections.Counter几行就能搞定。
最后一个血泪教训:不要在孩子面前调试出题器。我曾经当着孩子的面改代码,结果生成了一套除法题全是81 ÷ 9,孩子做了五遍同样的题,当场识破。后来我养成的习惯是先在本地跑一遍,用assert检查题目数量和重复率,确认无误再打印。出题器的价值不在于代码多优雅,而在于孩子拿到手的那张纸是不是真的在帮他练习,而不是在帮他背答案。希望帮到你。
本文还有配套的精品资源,点击获取