如果你正在刷在线编程题库,遇到“德才论”这道题,大概率会被它的分类和排序绕晕。题目标号是1015,难点不在于算法本身,而在于怎么把题目的规则精确翻译成代码逻辑,以及处理一堆隐藏的测试点。这一篇我把完整思路、Python实现和踩过的坑一起整理出来,适合正在刷题准备机试的初学者,也适合想系统梳理排序分类思路的朋友。
1. 题目到底在考什么:排序还是分类
1.1 两条线:最低录取线与优先录取线
题目会给N个考生,每个考生有考号、德分、才分三个属性。录取规则先看两条分数线:最低录取线L和优先录取线H。德分和才分都达到L的考生才“有资格”,任何一门低于L的直接排除,不问理由。注意这里用的是“都达到”,也就是说只要德分或者才分有一门低于L,这个人就出局了,不管另一门多高。
很多人一上来就想着“模拟录取流程”,其实不用这么复杂。题目的本质就是分类加排序,先把合格的人挑出来,然后按德才表现划分成四类,再按统一规则排序输出。
注意:L和H都是整数边界,判断条件一定要写成 >= L,而不是 > L。测试点专门会卡恰好相等的情况,差一个等号就是错一排。
1.2 四类考生的划分规则
德才论把合格考生分成四类,优先级从高到低,规则如下:
| 类别 | 条件 | 含义 |
|---|---|---|
| 第一类 | 德分 >= H 且 才分 >= H | 德才兼备,全优 |
| 第二类 | 德分 >= H 且 才分 < H | 德高分低,才分不够优秀 |
| 第三类 | 德分 < H 且 才分 < H 且 德分 >= 才分 | 德才都低于H,但有德优先 |
| 第四类 | 达到L但不符合以上三类 | 过线但有瑕疵,排在最后 |
这里的第三类最容易被写错。注意它是“德分 < H”且“才分 < H”,德分不低于才分。如果一个人的德分和才分都低于H,但才分比德分高,那只能归为第四类。
我当时第一版代码就把第三类写成了 else if de >= cai,没有限定德分才分都低于H,直接把第四类的一部分划到了第三类里,测试点挂得很惨。
1.3 输入和输出的格式陷阱
输入的第一行是三个整数:考生总数N、最低线L、优先线H。接下来有N行,每行是考号、德分、才分。注意考号在题目里通常是一个定长数字编号,读进来后可以当作字符串处理,输出时原样输出就行。
输出格式要求:第一行输出实际录取人数,接下来按排序结果逐行输出考号、德分、才分。这里有一个容易忽略的点:如果录取人数为0,第一行输出0,后面不要有任何多余空行。用字符串拼接时很容易多出一个换行,这一步坑了不少人。
2. 排序规则的降维:多关键字排序怎么设计
2.1 优先级拆解:四级排序键
题目给出的排序规则是:类别优先级高的先输出;同类别下按总分从高到低;总分相同时按德分从高到低;德分还相同按考号从小到大。
翻译过来就是排序键的四级结构:
- 类别编号(0到3,越小越靠前)
- 总分降序
- 德分降序
- 考号升序
注意这里“德分还相同按考号从小到大”,考号是数字编号,但题目里不会出现负数,用字符串正序排序即可,前提是所有编号长度一致。如果长度不一致,字符串排序会出现“9”排在“10”后面的反直觉结果。
2.2 元组与负号:Python排序的核心写法
Python的列表排序可以传一个元组作为key,元组比较时逐元素比较。要点是有三个:
- 类别编号默认升序,0、1、2、3正好对应要求。
- 总分、德分需要降序,而元组排序默认升序,所以用负号取相反数。
- 考号用原值,升序。
例如第一类的排序:
group[0].sort(key=lambda x: (-(x[1] + x[2]), -x[1], x[0]))x[1]是德分,x[2]是才分。总分取负,德分取负,考号原值放第三个。运行效果就是:总分降序,总分一样时德分降序,德分一样时考号升序。这个写法比自定义比较函数快得多,代码也简洁。
2.3 分数相等:最容易翻车的细节
如果两个人的总分和德分都相同,就比较考号,小的排前面。考号是数字编号时,字符串升序在小端位数一致的情况下等价于数字升序,所以用字符串直接比较没问题。保险做法是读入时保留字符串形式,排序时原样使用。
有人写排序键时手滑写成 -x[0],导致考号倒序输出,这种错误不容易在随手的样例里发现,因为普通测试数据不会专门构造总分、德分都相同的组合。所以我建议把排序键写完后默念一遍“总分负、德分负、考号正”,确认无误再提交。
3. Python 实现从零到满分的完整链路
3.1 读取输入:三种方式怎么选
N的最大值在题目里可以达到十万级别,虽然不算特别大,但如果在Python里用 input() 逐行读取几十万次,耗时很难看。这里我推荐用 sys.stdin.buffer.read() 一次性读取全部输入,再按空白字符拆分,速度非常稳。
import sys def main(): data = sys.stdin.buffer.read().split()读进来后 data 是字节串列表,int() 能直接解析字节串,但考号需要解码成字符串,否则后续拼接输出时会带上 b 前缀。所以我在循环里用 decode() 处理考号。
实际对比看,三种方式的取舍如下:
| 读取方式 | 适合场景 | 速度 |
|---|---|---|
| input() | N很小,最多几千 | 较慢 |
| sys.stdin.buffer.readline() | N几万到十几万 | 中等 |
| sys.stdin.buffer.read() | N十万以上 | 最快 |
这道题N通常在十万以内,但为保险起见直接采用 read 方案,代码改动不大,收益却很明显。
3.2 筛选与分类写在一遍循环里
读取每个考生三元组时,先判断是否达到最低线L,没达到直接跳过;达到了再判断属于第几类,把三元组加入对应类别列表。这样一个循环同时完成筛选和分类,后面排序之前已经按类别分好,省去了先全部塞一个列表再二次分类的麻烦。
n, L, H = int(data[0]), int(data[1]), int(data[2]) groups = [[], [], [], []] idx = 3 for _ in range(n): num = data[idx].decode() de = int(data[idx + 1]) cai = int(data[idx + 2]) idx += 3 if de < L or cai < L: continue if de >= H and cai >= H: groups[0].append((num, de, cai)) elif de >= H and cai < H: groups[1].append((num, de, cai)) elif de < H and cai < H and de >= cai: groups[2].append((num, de, cai)) else: groups[3].append((num, de, cai))注意第二类我用的是 de >= H and cai < H,写完整条件语义更清晰。理论上走到这一步且 de >= H 时,cai 不可能也 >= H,因为那样已经进了第一类,但显式写出来能减少心智负担。
第三类同理,走到这里的人已经排除前两类,de 不可能 >= H,但把 de < H and cai < H 都写出来,读代码的人一眼能看懂是题目原意。
3.3 输出设计:避免多余换行
输出时先统计总人数,然后把各类别排序后的结果拼接成字符串列表,最后用一次 sys.stdout.write 输出,避免 print 多次调用带来的性能损耗。
lines = [str(sum(len(g) for g in groups))] for g in groups: g.sort(key=lambda x: (-(x[1] + x[2]), -x[1], x[0])) for num, de, cai in g: lines.append(f"{num} {de} {cai}") sys.stdout.write("\n".join(lines))如果录取人数为0,lines 里只有 "0",write 不会在末尾多加换行,正好满足要求。之前我试过在末尾再 append 一个空字符串,结果输出多了一个换行,被判定格式错误。
3.4 完整可运行的参考代码
下面是一份可以直接跑通的完整代码,注释写在关键位置:
import sys def main(): data = sys.stdin.buffer.read().split() if not data: return n, L, H = int(data[0]), int(data[1]), int(data[2]) groups = [[], [], [], []] idx = 3 for _ in range(n): num = data[idx].decode() de = int(data[idx + 1]) cai = int(data[idx + 2]) idx += 3 if de < L or cai < L: continue if de >= H and cai >= H: groups[0].append((num, de, cai)) elif de >= H and cai < H: groups[1].append((num, de, cai)) elif de < H and cai < H and de >= cai: groups[2].append((num, de, cai)) else: groups[3].append((num, de, cai)) lines = [str(sum(len(g) for g in groups))] for g in groups: g.sort(key=lambda x: (-(x[1] + x[2]), -x[1], x[0])) for num, de, cai in g: lines.append(f"{num} {de} {cai}") sys.stdout.write("\n".join(lines)) if __name__ == "__main__": main()这份代码在时间复杂度和空间复杂度上都没有明显瓶颈,读入、分类、排序、输出四个环节都用了比较稳妥的写法,刷题平台实测下来表现稳定。
4. 测试点问题实录与排查技巧
4.1 全员淘汰:输出零人时别留空行
最典型的边界情况是:所有考生都有一门或两门低于L,录取人数为0。此时应该输出一个单独的 "0",后续什么也不输出。我第一次提交时在 lines 列表末尾多加了一个空字符串,结果输出多了一个换行,被判格式错误。
排查思路很简单,本地构造几组全是低分的数据跑一遍,用十六进制工具看输出末尾有没有多余的 \n。我后来改成只在列表里放 "0" 并用 join 拼接,问题彻底解决。
4.2 同分不同名次的隐藏用例
题目会专门构造一组总分、德分都一样,考号乱序的数据,用来检测考号排序有没有写对。常见的错误就是排序键里不接考号,或者手滑给考号加了负号。这里记住一句话:考号升序,不加负号。
另外还要注意,如果考号是整数而不是字符串,排序键同样写 x[0] 正序即可。但如果你把考号解码成了字符串,那就要确保所有编号等长,否则“100”和“99”会排成“100”在前、“99”在后,不符合数字大小顺序。
4.3 输入规模大导致超时
当N达到几十万时,如果还用 input() 逐行读取,Python很容易超时。我的排查习惯是先把输入输出部分替换成 sys.stdin.buffer 方案,再提交看结果。大部分时候超时问题都出在 IO,而不是排序本身。
排序那一步的时间复杂度是 O(N log N),在十万规模下完全没问题。真正拖后腿的是万次级别的 print 调用,因为每次 print 都要做一次系统调用,累积起来耗时很客观。
4.4 第三类条件边界:分支顺序的坑
题目描述里第三类要求“德才分均不及 H 但德分 >= 才分”。这个“均不及H”是很多人漏掉的条件。
如果你写出这样的判断链:
- 第一类:de >= H and cai >= H
- 第二类:de >= H(隐含 cai < H)
- 第三类:de >= cai
那么当一个考生 de < H、cai < H、de < cai 时,第三类条件 de >= cai 不成立,会掉进第四类,逻辑正确。但如果有人把第二类写成 de >= H or cai >= H,或者把第三类放在第二类之前,那结果就完全乱了。
我的建议是写完整条件,不要依赖分支之间的隐含关系。代码长了点,但每个条件都是自解释的,调试时不容易把自己绕进去。
4.5 筛选条件里的“达到”边界
L 和 H 是整数分数线,“达到L”意味着 de >= L 且 cai >= L;“达到H”意味着 de >= H 且 cai >= H。很多人在第一版会写成 de > H,结果恰好等于 H 的考生被错误排除,导致通过率掉一个测试点。
我自己就栽过一次:当时为了省字符,把 de >= H and cai >= H 写成了 de > H-1,逻辑上等价,但少考虑负数分数的情况。如果测试点里出现负数,这个写法就会出问题,所以最好老老实实用 >= 直观表达。
5. 避坑技巧与高效写题习惯
5.1 分类逻辑先画分支图再写代码
面对这种多条件分类,我习惯先在白纸上写一个分支树,把所有可能的情形列出来。德分和才分相对于H的位置一共有四种组合,再加上L的限制,总共没多少分支。画完分支树再写代码,顺序永远清晰,不会漏条件。
5.2 善用列表分组代替额外类别字段
因为题目要求先按类别输出再按分数排序,所以我在读入时就按类别分到四个列表,每个列表内部再排序。这样不需要在元组里额外保存类别编号,也避免了排序时类别编号影响分数排序的麻烦。
如果把所有合格考生放进一个列表再排序,就需要在排序键里加上类别字段,例如:
all_list.sort(key=lambda x: (x[3], -(x[1] + x[2]), -x[1], x[0]))其中 x[3] 是类别编号。这样做也能正确,但每个元组多存一个字段,逻辑上不如分组清晰。
5.3 善用 sys.stdin.buffer 与格式化拼接
刷题时 IO 性能往往决定这道题能不能通过。我的固定习惯是:只要输入数据量超过一万,就无脑用 sys.stdin.buffer.read().split(),输出用列表收集后一次性 write。这个习惯帮我避开了很多次超时。
格式化输出时,用 f"{num} {de} {cai}" 比用 "%s %s %s" % (...) 可读性更好,性能差别也不大。测试数据量大时,f-string 的解析速度已经足够快,不用刻意优化。
5.4 构造自己的随机测试样例
与其盯着测试点一个个猜,不如写一个简单的数据生成器,随机生成几百组数据,再用暴力排序方法对照验证自己的代码结果。具体做法是:
- 写一个简单的参考实现,比如用 sorted 函数配合完整四元组排序。
- 随机生成 N、L、H 以及若干考生数据。
- 对比两个程序的输出是否完全一致。
发现不一致,再打印具体输入定位问题。这个方法在排查排序和边界条件时非常高效,比反复提交测试代码省时间得多。
6. 个人经验与后续扩展
说一点刷题之外的体会。德才论这道题,本质上是在考察“把自然语言规则转成程序逻辑”的能力。题目背景借用了古代的选人标准,但落到计算机上就是分类、筛选、排序三件事。能把这些步骤拆干净,代码自然清晰,测试点也自然好处理。
我前后提交过很多次,第一次挂在第三类边界,第二次挂在全员淘汰时多输出了一个空行,第三次才彻底通过。后面每次做类似的排序分类题,我都会先把类别划分写成明确的条件分支,再用元组排序一把梭,基本不会再在这些细节上出错。
如果你也想在同类题上少走弯路,可以把这道题的思路迁移到其他“多关键字排序”问题上,比如学生综合测评排名、比赛晋级排序等。套路都是先分类、再定排序键、最后统一输出。
最后分享一个小技巧:如果某个测试点始终过不去,别急着猜隐藏条件,试着把输出格式和排序键逐项和题目原句对一遍。很多时候问题出在“达到”“不低于”“相等时”这些字眼上,而不是算法本身。把题目的每个限定词都圈出来,代码里一定能找到对应的判断,找不到的就是漏了。