我做了什么
600 名虚构考生、30 个报考单位、5 个岗位、20 个考场 × 30 座。我用 Excel 里最常见的两种排法各排了一遍,再用 40 行 Python 排了一遍,三份座位表按同一套规则校验。结论就在标题里:Excel 那版 580 对相邻座位里有 560 对是同一个单位,脚本那版是 0 对。下面的数字都能用文末脚本复现,数据是造的,规则是真的。
背景:为什么大家还在用 Excel
考务公司的排考场多半是这么起步的:公告发出去,报名表收回来,一个 xlsx,按单位排个序,从第 2 行开始往下贴座位号,30 个一行,一个考场一屏。零成本,谁都会,临时招来的学生助理培训十分钟就能上手。这不是偷懒,是资源约束下合理的做法——排一场 600 人的考试,服务费可能就几千块,不值得先买一套系统。
问题出在量变到质变那一步:报考单位超过 20 个、考场超过 10 个、开考前临时改动的考生超过 5 个,Excel 排出来的表就没人能证明它是对的。而"能不能证明它是对的",恰恰是考务事故里最容易被追责的那一环。
问题一:Excel 会排序,不会"约束"
排考场本质是给每个座位找一个满足条件的人。规则通常三条:同一单位的人不相邻、同一岗位的人分散、每个考场人数相等。Excel 的排序只能处理一条,而且方向是反的——它把同单位的人排到一块儿(这正是排序的定义),恰好是需要避免的。
实测数据(580 对相邻座位,同考场内座位号相差 1 的两两组合):
| 排法 | 同单位相邻 | 同岗位相邻 |
|---|---|---|
| 按单位排序后顺序贴座 | 560 / 580 | 0 / 580 |
| 随机打散后顺序贴座 | 20 / 580 | 110 / 580 |
| 逐个考场回溯求解 | 0 / 580 | 0 / 580 |
非显然结论在这:让助手"手动打散"确实能把同单位相邻从 560 压到 20,但同岗位相邻从 0 顶到 110。两个约束互相打架——单位内岗位本来就是轮转分配的,你打散了单位,等于把岗位也一起随机了。这就是手工排表总要来回挪的原因:第 18 个考场排舒服了,前面 17 个又露出两个同单位的。
Excel 里没有"约束"这个对象,只有单元格里的值和公式,约束只能靠人眼盯。
问题二:改 1 个人,463 个座位号跟着变
考务现场的变动是常态:资格审查刷掉两个、有人弃考、有人身份证号填错要改报考单位。Excel 里最快的改法是删行,下面的行整体上移一行。
实测:删掉第 137 号考生那一行,600 人的座位表里有 463 人的座位号变了,横跨 16 个考场。这 463 份准考证如果是提前打印或已发到考生手机上,全部作废。
rows=sorted(cands,key=lambdar:(r["unit"],r["id"]))seats=[{"id":r["id"],"room":i//30+1,"seat":i%30+1}fori,rinenumerate(rows)]rows2=[rforrinrowsifr["id"]!=137]# 删掉一个缺考的seats2=[{"id":r["id"],"room":i//30+1,"seat":i%30+1}fori,rinenumerate(rows2)]print(sum(1fora,binzip(seats,seats2)ifa["room"]!=b["room"]ora["seat"]!=b["seat"]))座位号是"行号算出来的",行号一动,座位号就动。这不是操作失误,是按行号定位这种设计本身的必然后果。要让它不动,座位号必须是存下来的数据,不能是算出来的位置。
问题三:编号一换格式,两张表就关联不上
这一条最隐蔽,也最容易在考试前一天晚上爆。报名表里的报考序号是文本(0137,带前导零),准考证表里存成了数字(137)。两张表按序号一关联,600 行全部匹配不上。
text_ids=[f"{c['id']:04d}"forcincands]# 报名表:'0137'num_ids={c["id"]forcincands}# 准考证表:137print(sum(1fortintext_idsiftinnum_ids))# 0print(sum(1fortintext_idsifint(t)innum_ids))# 600同一个坑还有共享表格的并发:两个人同时改,各自看到的是自己那份,谁后点保存谁说了算。实测两个人各改 8 条,后保存的那次写盘为准,前一个人的 8 条缺考记录静默消失,表格不报警、不弹窗、不留痕。
换成代码怎么排:40 行、5.53 毫秒、0 违规
排考场是约束满足问题:给每个座位挑一个"和前一座位不同单位、不同岗位"的人,挑完从候选池里拿走。逐个考场贪心,某个考场排不下去就整场重来(随机顺序换了,结果就变了):
defbuild(rooms=20,seats=30,seed=7):rnd=random.Random(seed)pool=[dict(c)forcincands]plan=[]forroominrange(1,rooms+1):whileTrue:rnd.shuffle(pool)seq,used=[],set()forsinrange(1,seats+1):pu=seq[-1]["unit"]ifseqelseNonepp=seq[-1]["post"]ifseqelseNonepick=next((cforcinpoolifid(c)notinusedandc["unit"]!=puandc["post"]!=pp),None)ifpickisNone:break# 本考场排不下去,整场重来used.add(id(pick))seq.append({**pick,"room":room,"seat":s})iflen(seq)==seats:plan.extend(seq)pool=[cforcinpoolifid(c)notinused]breakreturnplan实测结果:600 人全部落座,5.53 毫秒,0 次重来,同单位相邻 0 对、同岗位相邻 0 对。
真正的差别不在速度,在于"能验证"。同一份座位表丢进一个 20 行的校验函数,违规数立刻出来;Excel 那版只能人工逐考场数,20 个考场 580 对,数到第 6 个考场眼睛就花了,剩下的靠"应该没问题"。
结果与数据
| 指标 | Excel 排序法 | 随机打散 | 脚本求解 |
|---|---|---|---|
| 同单位相邻 | 560 / 580 | 20 / 580 | 0 / 580 |
| 同岗位相邻 | 0 / 580 | 110 / 580 | 0 / 580 |
| 改 1 人后座位号变化 | 463 人 / 16 考场 | — | 座位号入库,只动被改的那 1 人 |
| 排一遍耗时 | 0.38 ms + 人工贴号 | 0.55 ms + 人工贴号 | 5.53 ms 全流程 |
| 能否机器校验 | 否 | 否 | 是 |
耗时那一列得说清楚:Excel 的 0.38 毫秒只算了排序和编号,人工贴号、复检、改错的时间没算进去,那才是大头;脚本的 5.53 毫秒是排完即成品。
完整代码已开源(含座位编排、审核规则引擎等核心模块):https://gitee.com/281064811/qiuzhizhaopin