先说明一下,下面这篇是我按“博主实战分享”的调性写的,不是照抄官方题解,也不是把题目原文搬过来。主要围绕P1068这道题从“读题-设计-代码-踩坑-考场策略”整条链路展开,配合我实际刷题时记录过的一些细节,尽量写得像你在论坛里刷到的一篇经验帖。
适合刚刷完排序模板、想找一道“带点应用场景”的题练手的新手,也适合准备NOIP普及组、想复盘排序类题目常见坑的选手。
1. 题目到底在考什么:把“分数线划定”这五个字拆开看
1.1 这题不是单纯让你排个序
我最早在洛谷刷到P1068的时候,第一反应是“这不就是个sort嘛”,结果写出来WA了两发,才老老实实回去重新读题。
原题是NOIP 2009普及组第二题,背景大概是:有n个选手参加笔试,成绩出来之后要按计划录取m人,但录取不是简单取前m名,而是先算出“面试分数线”——也就是排名在第int(m×1.5)位选手的分数。注意,分数线定下来之后,所有笔试成绩不低于这个分数的人,统统都要进入面试。换句话说,实际面试人数可能比m大,因为同分的人不能只取其中一个,否则对选手不公平。
这就是这道题和纯排序题的本质区别:它考的不是“会不会排”,而是“排完之后,你敢不敢把取数逻辑写对”。
知识点拆开来看,其实就那么几个:
- 结构体存储选手信息;
- 按成绩降序排序,成绩相同的按报名号升序;
- 计算 int(m×1.5) 的准确写法;
- 从排序结果中找出所有成绩不小于分数线的选手,并统计人数。
听起来是不是都不难?但拉通写一遍,你会发现每一步都有小坑。这也是为什么这道题在洛谷的难度虽然不是特别高,但讨论区里“WA求助”的帖子从来不少。
1.2 完整流程拆解:排序、定线、扩容
我习惯把这种题切成三步来想,每一步都对应用例里的一个输出部分。
第一步,读入所有选手的报名号和笔试成绩,放进结构体数组。
第二步,排序。排序规则有两条:先按成绩从高到低;如果成绩一样,再按报名号从小到大。这个“报名号升序”很多人会漏,等下我会专门说。
第三步,确定分数线。先把计划录取人数m变成实际用来切线的排名位置pos = int(m×1.5)。然后在排好序的数组里,第pos个人(从1开始数)的成绩,就是面试分数线。
第四步,统计最终录取人数。从数组头开始往后扫,凡是成绩不低于分数线的,全部计入。因为数组已经按成绩降序排好了,所以一旦发现某个选手成绩低于分数线,就可以直接break,后面不可能再有成绩回升的选手。
最后输出:第一行是分数线,第二行是实际录取人数,之后按报名号升序把录取选手的报名号和成绩逐行打出来。
这里要特别注意:输出顺序是报名号升序,不是成绩升序。如果你改了排序规则,把“报名号升序”这件事弄丢了,那即使你算对了分数线和人数,输出顺序也会被判定为错误。
1.3 数据范围与用不用结构体的问题
P1068的n和数据规模放在今天看其实很温和,n最大也就5000左右,但你依然需要在“怎么存”这件事上做个选择。
很多新手习惯平行数组,就是开一个int id[5005],再开一个int score[5005],然后排序的时候就只排score,id跟着动。这种写法在只有两个字段时不至于完全没法写,但一旦比较规则稍微复杂一点,平行数组很容易出现“成绩排序了,报名号没跟上”的经典事故。
所以我的建议是:老老实实用结构体。
cpp复制struct Person {
int id;
int score;
};
这个数据结构本身不复杂,但它为后续的排序、取数、输出都提供了统一的载体。你不需要关心id和score是不是“黏在一起”的,因为结构体数组天然保证了它们永远是一个整体。
如果观察再大一点的数据范围,比如n到1e5,结构体数组依然完全够用,因为排序是O(n log n),扫描是O(n),整体复杂度非常健康。这道题没有任何卡常的必要,真正卡人的是逻辑细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序这步没那么简单:双关键字排序到底怎么写
2.1 只按成绩排序,为什么一定会出错
我见过不少人的第一版代码长这样:用默认sort,或者自己写一个冒泡,只比较score,score大的往前放。
单单这么写,排序结果会把“成绩相同的人”之间的相对顺序变得不确定。如果sort内部实现不稳定(C++的sort本来就是不稳定排序),那么成绩相同的选手之间,id顺序可能被打乱。而题目明确要求“如果成绩相同,则按报名号升序排列”,你不处理这一条,输出顺序就错了。
有些同学可能会说“我把报名号也输出了啊”,但输出顺序和输入顺序可能不一致。题目要的比较器不是默认的,而是:
- 第一优先级:score降序;
- 第二优先级:id升序。
写成C++的cmp函数是这样:
cpp复制bool cmp(const Person &a, const Person &b) {
if (a.score != b.score) return a.score > b.score;
return a.id < b.id;
}
这里有一个容易被忽略的细节:为什么成绩相同要返回id的升序比较,而不是直接return false?因为sort要求严格弱排序,如果两个元素所有维度都等价,那返回false没问题;但题目要求id升序,这是一个明确的大小关系,必须写进比较规则里,而不是寄希望于sort保持输入顺序。
2.2 自己写冒泡还是直接用sort
这道题用冒泡排序也能过,因为n不大。但我不建议为了AC就止步于冒泡。你刷题的目的是练“解决问题的通法”,不是练“在这个数据范围下刚好能过”。
我建议你直接用algorithm头文件里的sort,原因有三个:
第一,sort平均复杂度O(n log n),比冒泡的O(n^2)稳。虽然这道题冒泡也能过,但遇到数据范围更大的题目时,冒泡就露馅了。
第二,sort配合cmp函数可以非常直观地表达双关键字排序的意图。你一看cmp函数,就能立刻说出排序规则;看冒泡里嵌套的if,要反应一下才知道谁先谁后。
第三,竞赛考场上时间宝贵,写sort比手写排序省下至少5分钟。这5分钟放在后面的题目上,价值完全不同。
如果你用的不是C++,比如Java或Python,思路也一样:Java用Comparator,Python可以用sorted的key传tuple,比如:
python复制persons.sort(key=lambda x: (-x[1], x[0]))
这里通过-x[1]实现成绩降序,用x[0]实现id升序。逻辑上和C++的cmp是完全等价的。
2.3 关于stable_sort:它在这题里有用吗
有同学会问:“我直接用stable_sort,那成绩相同的人是不是就能保持原来的相对顺序,我不就不用管id了吗?”
理论上stable_sort确实会保留相等元素的原始相对顺序。前提是:输入数据中报名号本来就是升序排列的。但你去看题目数据,并不保证这点。报名号只是不重复,不代表输入时就按报名号排好。如果你的数据输入顺序是乱的,stable_sort保留的“原始相对顺序”恰恰不是你想要的id升序。
所以结论很明确:别指望stable_sort救你,老老实实在cmp里写清楚id升序,才是最稳的做法。
这也是我经常说的:算法库给你提供的稳定性语义,解决的是“元素等价时的相对顺序”,而不是“题目自定义的第二关键字”。两者是不同层面的东西,初学者容易混。
3. 边界条件与踩坑记录:我是怎么从WA磨到AC的
3.1 分数线计算公式,整数运算才是正解
确定第pos名的位置时,公式是int(m×1.5)。很多人的第一反应是直接写:
cpp复制int pos = m * 1.5;
这个写法在数学上没错,但当m比较大的时候,浮点数的精度问题虽然概率低,却没必要去赌。更稳妥、更符合竞赛习惯的写法是完全用整数运算:
cpp复制int pos = m * 3 / 2;
因为int(m×1.5) = floor(m×1.5),而m×1.5 = m×3/2,整数运算向下取整,二者完全等价。这样写还避免了浮点数可能带来的精度误差,尤其在一些m值较大的测试数据里更安全。
算出来pos之后,还要注意数组下标从0开始。排好序的数组里,第1名是p[0],第2名是p[1],那么第pos名就是p[pos-1]。
cpp复制int line = p[pos - 1].score;
这个line就是面试分数线。
很多WA的代码错在哪呢?错在直接用p[m]或者p[pos],那取到的其实是“第pos+1名”的成绩,分数线就偏了,后面统计的人数自然全错。
3.2 统计人数:for循环里的break条件怎么写
当line算好之后,统计录取人数的循环可以从数组头部开始遍历:
cpp复制int cnt = 0;
while (cnt < n && p[cnt].score >= line) {
cnt++;
}
因为数组按分数降序排列,所以只要碰到第一个低于line的选手,就可以直接结束统计,后面的选手分数只可能更低,不可能再进入面试。
反过来,如果你想用for循环从头到尾扫一遍,把每个p[i].score >= line的人都统计进去,也不是不行,结果一样。但从效率上来说,while循环扫到临界点就停,语义也更清楚,我推荐while。
我见过有些同学在统计人数时会写:
cpp复制for (int i = 0; i < n; i++) {
if (p[i].score >= line) cnt++;
}
这也能过,只是多扫了后面那些无意义的元素。数据量小无伤大雅,但你最好培养出“有序数据可以提前终止”的意识,这个习惯在刷更多题时会帮你省下大把时间。
3.3 手动模拟一组数据,走一遍完整流程
我不喜欢空讲逻辑,干脆带大家手推一组小数据。
假设n=6,m=3。
选手信息如下:
- 1001 88
- 1002 76
- 1003 92
- 1004 88
- 1005 70
- 1006 85
第一步,排序。按成绩降序,成绩相同的按报名号升序,得到:
- 1003 92
- 1001 88
- 1004 88
- 1006 85
- 1002 76
- 1005 70
第二步,计算pos = int(m×1.5) = int(3×1.5) = 4。注意这里不是3,不要被m=3带偏。
第4名是1006,他的成绩是85。所以面试分数线line=85。
第三步,统计不低于85分的人:1003(92)、1001(88)、1004(88)、1006(85),一共4人。也就是说,实际录取4人,超过了最初计划的3人。
输出时,先输出“85 4”,再按id升序输出这4个人的信息:
- 1001 88
- 1003 92
- 1004 88
- 1006 85
注意:虽然排序后1003排在最前面,但输出时要按id升序重新排列,所以1001要在1003前面。这一步是很多人最后丢分的地方。
3.4 常见错误与排查速查表
我把这道题踩过、看别人踩过的坑整理成一张表,刷题时可以直接对照自查。
| 症状 | 原因 | 解决办法 |
|---|---|---|
| 输出分数线偏高或偏低 | pos算错,或数组下标取错 | 用 m*3/2 计算pos,分数线取 p[pos-1].score |
| 输出人数不对 | 没有统计所有同分选手 | 用while循环从前往后扫到第一个小于line的选手 |
| 输出顺序报错 | 排序时漏了报名号升序 | cmp里先比成绩后比id |
| 分数线下取整错误 | 用了floating point计算pos | 全部改用整数运算 |
| 边界数据WA | 数组越界或循环条件写错 | 确认n、pos、cnt的范围,别把p[n]读进来 |
如果你提交后WA,第一时间先自己造几组小数据,验证三步流程:排序是否对、分数线是否对、输出顺序是否对。这三个只要有一个不对,整题就AC不了。
4. 从P1068看竞赛排序题的通用套路
4.1 这类题的基本盘:排序 + 扫描
P1068表面是“分数线划定”,本质上是一个“排序 + 条件扫描”的模板题。在NOIP普及组里,这类题目非常常见,比如按总分排名、按某科成绩划线、按综合得分筛选,换壳不换核。
通用的解题套路是五步:
- 明确排序对象,选择合适的结构体。
- 厘清排序规则,包括主关键字和次关键字。
- 用cmp函数或lambda表达式明确表达规则。
- 根据题意计算结果,可能是取某个排名、算某个分数、统计某个区间。
- 按照题目要求的输出格式,原样输出。
这五步在平时练习时,可以当作一套“检查清单”。每次做完排序类题目,就对着清单过一遍,看看自己有没有漏掉某一步。
以P1068为例,很多人卡在第2步(排序规则漏了id),也有很多人卡在第4步(统计人数只算到pos)。这些都是清单上的具体事项。
4.2 结构体排序的代码模板
下面是P1068的完整AC代码,我注释写得比较细,可以直接参考。
cpp复制#include <cstdio>
#include <algorithm>
using namespace std;
struct Person {
int id;
int score;
};
// 排序规则:分数降序,分数相同时按报名号升序
bool cmp(const Person &a, const Person &b) {
if (a.score != b.score) return a.score > b.score;
return a.id < b.id;
}
int main() {
int n, m;
scanf("%d%d", &n, &m);
Person p[5005];
for (int i = 0; i < n; i++) {
scanf("%d%d", &p[i].id, &p[i].score);
}
sort(p, p + n, cmp);
// int(m * 1.5) 的整数写法
int pos = m * 3 / 2;
// 第 pos 名对应数组下标 pos-1
int line = p[pos - 1].score;
int cnt = 0;
while (cnt < n && p[cnt].score >= line) {
cnt++;
}
printf("%d %d\n", line, cnt);
for (int i = 0; i < cnt; i++) {
printf("%d %d\n", p[i].id, p[i].score);
}
return 0;
}
如果你想把数据存成vector,改成:
cpp复制vector<Person> p(n);
sort(p.begin(), p.end(), cmp);
一样能过。
4.3 考场上这道题应该花多少时间
NOIP普及组的T2难度,一般不会太离谱。如果你能稳定地在10到15分钟内完成读题、写代码、自测,那这个节奏是合理的。如果你花超过20分钟,说明排序这块的基本功还不够扎实,需要再多刷几道同类型的题找手感。
我个人的建议是:在比赛前,把近十年的普及组T2都拉出来过一遍,不用多,每天两道,重点不是追求AC,而是训练一种“读完题就能立刻映射到算法模板”的能力。P1068是一个好起点,因为它虽然简单,但把结构体、双关键字排序、边界条件计算这三样基本功都覆盖了。这三样东西,在后续题目中几乎无处不在。
另外一个小技巧:如果你在做P1068的时候发现需要反复调试输出格式,可以先把输出部分的代码写成一个函数。这样做的好处是,当排序规则或筛选条件改来改去时,你只需要保证最终传给输出函数的是一个已经按正确顺序排好的数组。这个习惯在代码量变大时特别有用。
我自己的做题习惯是先写一个“最笨但肯定正确”的版本,比如用冒泡排序也能过的那版,先保证AC,然后再改成sort版本,对比两种写法的时间差距。这样不仅能加深印象,也能让你更直观地感受到排序算法在教学和实际竞赛中的差异。毕竟,算法题不是一次AC就结束了,能从中提炼出通用模板和易错点,才算真正吃透了它。
