简介:一套基于遗传算法智能组卷的在线考试系统源码,采用SpringBoot+Vue前后端分离架构,完整覆盖管理员、教师、学生三类角色,支持题库刷题、手动/随机/智能组卷、在线答题、成绩可视化、错题本等考试业务闭环。系统适合计算机、数学、电子信息等专业作为课程设计、期末大作业或毕业设计参考,也适合希望研究遗传算法在组卷问题中实际落地、并具备一定代码阅读与调试能力的开发者。资源包共452个文件,包含151个XML配置、95个Java后端代码、95个编译class文件、48个Vue前端组件以及JS、JAR、JSON、图片等配套资源,压缩包约45.76MB,目录结构清晰完整,已有435人学习下载。借助此项目可快速搭建在线考试原型,深入研读智能组卷算法、权限分层设计、考试流程与数据交互等核心模块,为二次开发、功能扩展或论文撰写提供直接参考。
1. 智能组卷的在线考试系统,难的不是考试而是出题
如果你拆开这类毕设源码,会发现倒计时、答题卡、试卷批改几乎都是成熟组件,真正让组卷系统有技术含量的,是出题那一刻的组合爆炸:一门课几百道题,按题型、知识点、难度、分值拼出一张符合要求的卷子,候选方案的数量远大于暴力枚举能处理的范围。遗传算法在这里的价值,是它不需要你把所有组合列出来,而是用“适者生存”的方式在解空间里快速逼近一个可行且稳定的答案。这套系统面向的场景很具体:教师在前端填写题型分值、期望难度和知识点覆盖范围,后端调用遗传算法生成试卷草稿,再进入考试流程。适合两类人看:一类是正在做 Spring Boot + Vue 毕业设计、需要把算法讲清楚也把代码写明白的学生,另一类是准备把组卷功能塞进现有考试系统的后端工程师。前 100 字里已经把标题的核心词都带出来了,下面直接从建模开始。
2. 把组卷问题建模成可计算的目标:难度、知识点、分值的约束表达
2.1 组卷为什么是多约束组合优化
随机抽题看起来简单,实际跑两次就会露馅:第一次难度均值 0.82,学生叫苦;第二次难度均值 0.41,全班满分。原因是难度只是一个统计期望,随机组合的结果方差极大,知识点覆盖也可能扎堆在某两章。组卷问题本质是一个带约束的组合优化:题型数量固定、单题分值固定、总分固定、难度区间固定、知识点覆盖要求固定,目标是在这些约束里找到一组题,让卷子的整体难度逼近教师期望,同时知识点覆盖尽量全。
这类问题用贪心或回溯也能做,但题库规模超过几百道后,回溯的剪枝条件会越写越多,而遗传算法天然适合这种多目标权衡。它不追求一次找到精确最优解,而是在有限迭代次数内给出一个“足够好”的卷面,并且每次生成的结果和教师手调的卷子在同一水平线。这正好符合考试系统的真实需求:教师可接受的是 90 分的卷面质量标准,而不是数学意义上的最优解。
2.2 编码方式:分段整数编码与题库候选池
遗传算法的每一步操作都建立在编码之上。常见的组卷编码有二进制编码、实数编码和整数编码,直接拿题库里的题目 ID 做整数编码是最省事的做法:一个个体就是一张卷子,基因序列是题目 ID 列表。但这里有个细节,一张卷子包含单选、多选、判断等多个题型,如果整段基因直接做交叉,很容易把单选题的基因片段换到多选题的位置上。因此我一般会采用分段整数编码:基因分成若干段,每段对应一个题型,段内顺序是题目 ID,段与段之间不参与交叉。
配套的做法是先在 SQL 层做候选池过滤。组卷接口接收到请求后,先按题型、知识点、难度上下限把候选题目捞出来,再丢给算法。比如单选题 10 道、每题 3 分,期望难度 0.7,那么候选池就是“题型=单选 AND 难度 BETWEEN 0.55 AND 0.85”的题目集合。这样做的直接收益是大幅缩小基因的取值空间,变异操作也不用担心生成一道毫无关系的题。算法内部只认识候选池里的题目索引,最终映射回题目 ID 时再做一次数据库查询。
2.2.1 候选池过滤的关键参数
候选池过滤的 SQL 条件不能只有题型的难度,还要考虑知识点权重。因为即便算法再好,如果某个低权重的知识点在候选池里只有三道题,反复交叉变异也跳不出这三道。建表时可以给知识点编号,题库表字段大致如下:
CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4填空 5简答', difficulty DECIMAL(3,2) NOT NULL COMMENT '0.10~1.00', knowledge_point VARCHAR(32) NOT NULL, score INT NOT NULL, question_text TEXT NOT NULL, options_json JSON, answer_json JSON );这个表结构几乎是此类系统的标准形态。knowledge_point用短编码而不是外键,是为了让过滤条件简单,避免组卷时多一次 JOIN。过滤时的高频条件就是这三条:type = ?、difficulty BETWEEN ? AND ?、knowledge_point IN (?, ?),配合 MySQL 的联合索引可以稳定在毫秒级返回候选池。注意difficulty的过滤范围不宜过大,上下浮动 0.15 左右是合理的,范围太大等于没过滤。
2.3 适应度函数:难度偏差、知识点覆盖、同卷重复惩罚
适应度函数是遗传算法的指挥棒,它决定了一个个体是好是坏。很多刚上手的人把精力放在交叉变异上,实际效果不佳的根源都在适应度设计不合理。组卷场景里,适应度通常由三部分组成:难度偏差分、知识点覆盖分、重复惩罚分。
难度偏差的计算要区分题型。比如整卷期望难度是 0.65,但单选题难一点、判断题简单一点是正常的,所以最好的做法是对每个题型单独算难度均值,再按题型的分数占比加权,而不是直接把整卷所有题拉平求均值。知识点覆盖则简单直接:统计个体覆盖的知识点数量,除以期望覆盖的总数。重复惩罚是针对多张卷子同时生成或者同一题库反复抽题的场景,如果两张卷子的题目重合度超过 30%,对双方都要扣分,否则并发组卷时会出现两张高度雷同的卷子。
伪代码写出来是这样:
def fitness(individual, config): diff_dev = 0.0 for seg in config.segments: seg_ids = individual.get_segment(seg.type) avg_diff = mean([pool[q].difficulty for q in seg_ids]) diff_dev += abs(avg_diff - seg.target_diff) * (seg.total_score / config.total_score) covered = len(individual.covered_kps()) coverage = covered / len(config.required_kps) duplicate = individual.duplicate_ratio(existing_papers) duplicate_penalty = duplicate if duplicate > 0.3 else 0.0 return 1.0 - 0.6 * diff_dev - 0.3 * (1.0 - coverage) - 0.1 * duplicate_penalty这段伪代码里权重 0.6、0.3、0.1 可以直接用。实际的调参经验是:难度偏差权重最大,因为这是教师能直观感知的指标;知识点覆盖权重其次,覆盖不全的卷子看起来会“偏科”;重复惩罚在单卷场景可以设为 0,在整库随机抽题场景才需要开启。如果发现生成的卷子难度总是偏离目标,优先检查的不是迭代次数,而是这个权重分配是否合理。
2.4 常见错误建模:分值和题数对不上就无从优化
我见过不少失败案例,问题出在建模阶段就错了。最典型的是只传了题数和难度,没传知识点权重,导致算法把某章题目全部选走;其次是遗传算法要求“每道题分值固定”,但有人为了让总分凑满 100 而在适应度里做动态分值修正,这会让同一道题在不同个体里价值不同,算法收敛会很慢。
正确的做法是:分值属于题目固有属性,在候选池过滤时就把“满足题型分值”作为硬条件,不需要跑进适应度函数。遗传算法只在“选哪些题”和“怎么组合”上做文章。如果总分凑不齐,调题型数量或单题分值,而不是在算法层做补偿。
3. 遗传算法核心流程:从初始种群到精英保留的最小可运行实现
3.1 种群初始化与合法性校验
组卷的种群初始化不是纯随机。纯随机会给每个个体生成完全无序的题目组合,导致一半个体连基本约束都不满足,例如同一个知识点塞了 8 道题、另一个知识点 0 道题,淘汰这些个体会让算法前期浪费大量迭代。我一般会用贪心辅助初始化:随机选题时带上知识点轮换逻辑,每选一道题就检查当前个体里这个知识点的题目数量,超过上限就在剩余题里重选。
种群里保留 10% 到 20% 的纯随机个体是有必要的,它们负责维持种群的基因多样性,防止所有个体过早陷入同一个局部区域。初始化解码后还要做一次合法性校验:题数对不对、总分是否等于卷面总分、有没有重复题。校验失败直接丢弃重建,而不是把非法个体留在种群里参与进化,否则后续交叉变异都会被带偏。
def init_individual(pool, config): genes = [] for seg in config.segments: candidates = pool.filter(type=seg.type) kp_groups = group_by_kp(candidates) chosen = [] kp_cycle = list(kp_groups.keys()) while len(chosen) < seg.count: random.shuffle(kp_cycle) for kp in kp_cycle: unused = [q for q in kp_groups[kp] if q.id not in chosen] if unused: chosen.append(random.choice(unused).id) if len(chosen) == seg.count: break genes.extend(chosen) return Individual(genes)这个初始化函数的核心机制是轮转法:先按知识点分组,每次随机打乱知识点顺序后依次取题。好处是初始个体就有比较均匀的知识点覆盖,后续适应度收敛更快。注意这里的seg.count是题量,不是分值,分值是题目自带的,不需要在基因层面表达。
3.2 锦标赛选择与分题型单点交叉
选择算子我常用锦标赛选择,因为它不需要全局排序,少量的比较就能给出足够好的选择压力。每次从种群中随机抽 3 个个体,选适应度最高的那个进下一代,重复直到新一代种群满员。锦标赛的 k 值不宜太大,k=3 会让弱个体的生存概率保持在合理范围,k 越强选择压力越大,但只要超过 5,种群的多样性下降很快,容易早熟。
交叉是组卷遗传算法里最容易做错的环节。整段基因均匀交叉会让题型碎片化,例如从父本 A 拿到 6 道单选、父本 B 拿到 4 道单选,凑不齐 10 道的单选题段。我做的是分题型单点交叉:把两个父本按题型分成若干段,每段独立设置交叉点,段内交换交叉点之后的部分。
def crossover(father, mother, config, rate=0.8): if random.random() > rate: return father, mother son_ids, dau_ids = [], [] offset = 0 for seg in config.segments: seg_len = seg.count f_part = father.genes[offset:offset + seg_len] m_part = mother.genes[offset:offset + seg_len] cross_point = random.randint(1, seg_len - 1) son_ids += f_part[:cross_point] + m_part[cross_point:] dau_ids += m_part[:cross_point] + f_part[cross_point:] offset += seg_len return Individual(son_ids), Individual(dau_ids)交叉后还需要去重检查。分段交叉后某个知识点的题目总数可能超过上限,这时需要从段内替换超出部分的题目,替换源是同题型同知识点的候选池剩余题目。这一步在代码实现上不复杂,但漏掉的话,适应度计算里会出现重复题影响难度均值,卷面质量也难以把控。
3.3 约束感知变异与精英保留
变异算子在组卷里承担局部修正的角色。最常见的是替换变异:随机选一个变异点,把对应位置的题目替换成同题型、同知识点、难度在 ±0.15 范围内的另一道题。这个“约束感知”很关键,决定了变异后个体是否需要重新检查约束。如果变异时完全不考虑知识点,变异一次就要重新校验知识点覆盖,计算成本高。变异率设置在 0.05 到 0.10 之间比较合适,超过 0.15 会让算法退化成随机搜索。
精英保留是防止最优个体被交叉变异破坏的必要机制。每一代先把适应度最高的 1 到 2 个个体原样复制到下一代,剩余名额再通过选择交叉变异生成。没有精英保留的组卷算法,经常会出现上一代已经找到一组很好的题,下一代交叉后全被破坏,适应度曲线来回震荡。
def evolve(pool, config, generations=120, pop_size=60, mutation_rate=0.08): population = [init_individual(pool, config) for _ in range(pop_size)] for gen in range(generations): population.sort(key=lambda ind: fitness(ind, config), reverse=True) next_gen = population[:2] # 精英保留 while len(next_gen) < pop_size: f = tournament_select(population, k=3) m = tournament_select(population, k=3) s, d = crossover(f, m, config) if random.random() < mutation_rate: s = mutate(s, pool, config) if random.random() < mutation_rate: d = mutate(d, pool, config) next_gen.extend([s, d]) population = next_gen[:pop_size] return max(population, key=lambda ind: fitness(ind, config))这段代码是组卷遗传算法的主循环骨架,tournament_select从种群随机抽 k 个个体取最优,mutate做约束感知替换。典型参数组合如:种群 60、迭代 120 代、交叉率 0.8、变异率 0.08、精英保留 2 个、锦标赛 k=3。在 300 道题目的候选池下,这个配置稳定生成一张卷子耗时在 200 毫秒量级,完全够交互式调用。
3.4 对照组:随机抽题在同指标下的表现
判断遗传算法参数是否有效的办法不是看卷面本身,而是用同一个候选池跑随机抽题做对照统计。随机抽样 1000 次,每次记录难度偏差和知识点覆盖率,然后看遗传算法给出的结果是否超过这个分布的上限。我实际跑过的结果是:随机抽题难度偏差平均在 0.09 左右,遗传算法在 30 代之后就能稳定压到 0.03 以内;知识点覆盖率的差距更明显,随机抽题经常卡在 70% 上下,遗传算法能稳定到 90% 以上。这个对照是组卷系统答辩或代码评审里很有说服力的部分,值得保留数据。
4. Spring Boot + Vue 前后端分离中把算法接进真实考试流程
4.1 Spring Boot 服务端:算法落位 Service 层与事务边界
遗传算法在 Spring Boot 里不需要单开线程池或引入额外框架,直接把它作为 Service 层的一个方法即可。推荐的包结构是service/PaperGenService、algo/GeneticPaperGenerator、algo/FitnessCalculator,算法逻辑与 Spring 的解耦做到“不依赖任何 Mapper 也能单测”。算法内部接收的是候选池列表和配置对象,输出的是题目 ID 列表,Mapper 只负责在生成卷面前捞取候选池、在生成后写入卷子表。
@Service public class PaperGenService { private final QuestionMapper questionMapper; private final ExamPaperMapper examPaperMapper; private final GeneticPaperGenerator generator; @Transactional(rollbackFor = Exception.class) public PaperDraft generate(PaperGenConfig config) { List<Question> pool = questionMapper.selectCandidates( config.getTypeScores().keySet(), config.getRequiredKps(), config.getDifficultyRange()); if (pool.size() < config.getTotalQuestionCount()) { throw new BizException("候选池题量不足,请调整知识点范围或难度区间"); } List<Long> questionIds = generator.generate(pool, config); ExamPaper paper = new ExamPaper(); paper.setTitle(config.getTitle()); paper.setDifficulty(config.getExpectedDifficulty()); paper.setSeed(config.getSeed()); examPaperMapper.insert(paper); insertPaperQuestions(paper.getId(), questionIds); return buildDraft(paper.getId()); } }这段代码体现了两个容易踩坑的点。第一个是候选池数量检查:如果候选池题量小于需求题量,算法再怎么迭代也拼不出完整卷子,与其让算法空跑不如前置报错。第二个是事务边界:generate方法的入口上加@Transactional,试卷主表和试卷题目明细表要么一起写入成功,要么一起回滚,避免出现“有试卷没题目”的半成品数据。
4.2 组卷配置接口与并发请求下的种子策略
前端组卷页面提交的参数有题库筛选、题型的题数和分值、期望难度、知识点权重。后端用一个POST /api/paper/preview接口接收配置并触发算法生成,生成结果先不落库,而是返回到前端预览;教师确认后再调用POST /api/paper/publish正式发布。这个两步流程非常关键,否则教师对生成的卷子不满意,要去数据库里删已落库的题目明细,操作成本高。
并发组卷场景需要考虑随机种子的隔离性。算法内部使用Random时默认是系统时间的函数,两个同时发起的请求可能拿到同一组随机序列。解决方式是让每次组卷请求携带一个seed,这个 seed 可以是请求时间加教师 ID 的哈希,存入试卷表后还能实现卷面复现——同一份配置和同一个 seed 必然生成同一张卷子。
4.3 Vue 端:路由参数、Axios token 拦截与考试倒计时
Vue 前端的页面结构围绕三个角色展开:教师端有题库管理、组卷配置、试卷列表,学生端有考试列表、考试页面、我的成绩。组卷配置页从试卷列表页跳转时,用路由参数带上试卷 ID 或模板 ID:
this.$router.push({ name: 'PaperConfig', query: { templateId: row.id, edit: '1' } });templateId用于回显这个模板已有的题型配置,edit=1表示编辑模式。这样刷新页面后参数仍在地址栏中,用户体验和数据恢复都更好。
前后端分离项目的接口鉴权统一交给 Axios 拦截器。登录成功后后端返回 JWT token 存在 Vuex 和 sessionStorage 里,请求拦截器把它塞进Authorization头,响应拦截器在拿到 401 时清除本地状态并跳转登录页:
service.interceptors.request.use((config) => { const token = store.state.token || sessionStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error)); service.interceptors.response.use( (response) => response.data, (error) => { if (error.response && error.response.status === 401) { store.commit('logout'); router.push({ path: '/login', query: { redirect: router.currentRoute.fullPath } }); } return Promise.reject(error); } );这段代码解决了前后端分离中 token 管理的两个核心问题:每次请求自动带凭证,以及登录态过期时的统一处理。考试页面的倒计时逻辑用setInterval定期递减剩余秒数,同时监听visibilitychange事件检测切屏:当标签页从后台回到前台时,累计切屏次数并弹窗提醒。提醒不做强制交卷,因为考试系统通常允许学生查资料后回来继续作答,但要在后台记录切屏事件供教师查看。
4.4 前后端联调时最容易卡住的两个地方
第一个是跨域配置。Spring Boot 接收 Vue 开发服务器的请求时,默认会被 CORS 拦截,需要在配置类里显式允许来源。注意开发环境下用http://localhost:5173(Vite 默认端口),如果用 Vite 配了代理,前端把/api代理到后端 8080 端口,就不会触发跨域,生产环境下前端静态资源由 Nginx 提供服务时也走同一域名反代,不需要额外的@CrossOrigin。
第二个是时间格式。后端返回给前端的日期时间默认是时间戳或带时区的格式,前端new Date()解析后展示的可能是 UTC 时间,比北京时间早 8 小时。我的做法是后端统一把时间序列化为yyyy-MM-dd HH:mm:ss字符串,在前端不做额外时区转换,这样考试倒计时和成绩发布时间都不会出现偏差。如果用的是 Spring Boot 3.x,注意javax包已迁移到jakarta,部分旧教程里的注解导入路径会报错。
5. 组卷质量的验证方法:种子复现、20 次统计与题库边界处理
遗传算法的验证和其他算法的区别在于,它带有随机性,不能拿一次运行结果当结论。我在实际项目里摸索出一套三步骤验证法,可以直接套用。
第一步是“同配置、同种子”的确定性验证。固定的seed必须生成一模一样的卷子。如果出现两次运行结果不一致,基本是随机数使用顺序不统一造成的,比如某些地方用了Math.random(),另一些地方用了seed初始化的Random实例,两者混用就会破坏确定性。修法是确保同一个配置seed只创建一次Random实例,所有随机操作都从它取数。
第二步是跨种子统计验证。同一份配置跑 20 次,每次使用随机 seed,统计这 20 张卷子的难度均值和知识点覆盖率的分散情况。分散度低说明算法稳定,分散度高说明适应度函数的权重配比有问题。我一般会用下面这段脚本做冒烟测试:
# 用 curl 循环调组卷接口,采集结果指标 for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/api/paper/preview \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"typeScores":{"single":30},"expectedDifficulty":0.7,"requiredKps":["K1","K2","K3"]}' \ | jq '.data | {diff: .diffDeviation, cover: .coverage}' done跑完 20 次后取平均值。正常情况下难度偏差的平均值应稳定在 0.03 以内,覆盖率在 90% 以上。如果某一次结果特别差,去查那一轮的候选池是否有被其他逻辑干扰,比如题目是否刚被逻辑删除导致候选池数量变化。
第三步是针对题库边界的处理。组卷时最容易暴露的问题是某个知识点的候选题目数量不够。我在组卷配置接口里做了一个约束检查:每个知识点需求的题量乘以题目数上限,超过候选池总量时直接提示“知识点 X 题库数量不足”。但这个提示不能阻止生成,正常做法是放宽约束并告知教师哪些知识点被退化为“尽量覆盖”——保持卷面可用,同时标注降级信息。
最后分享一个种子落库技巧:生成的试卷表里存seed和gen_config_json,也就是这次组卷的完整配置快照。当教师对卷子做出手动调整后,把gen_config_json中对应的题型难度同步修正,下次生成相似卷子时可以直接复用配置模板。这样系统里积累的卷子越多,组卷配置越完善,整体生成质量会随使用次数稳步上升。这也是遗传算法组卷系统在真实在线考试环境里比固定模板抽题灵活的根本原因。
本文还有配套的精品资源,点击获取