如果你刚开始在洛谷刷题,或者刚刚学会 C++ 里的 sort,想找一道既能用上排序、又带实际场景的题练手,那么 P1068《分数线划定》多半会被推荐到你的列表里。这道题出自 NOIP 2009 年普及组,名字听着像某个招生简章,实际要解决的事情也确实是“给一堆选手按规则排好顺序,再按一个分数线决定录取名单”。它标注的难度是普及-,属于很基础的那一档,但每年都有不少人在这种看似水题的题目上翻车。我见过有人 WA 到怀疑人生,最后发现只是排序忘了写第二关键字;也有人直接按 m*1.5 取整后的人数输出,结果怎么都过不了样例。这篇文章就把这道题从读题、建模、编码到调试的完整过程拆开来讲,适合刚学完排序的新手,也适合想系统过一遍普及组基础题的老手。
1. 题目到底在考什么:先读懂这套录取规则
1.1 一句话还原题目场景
很多同学第一眼看到“分数线划定”五个字,以为是模拟一个比赛晋级过程,结果一读题发现事情没那么简单。题目给了 n 名选手的报名号和成绩,又给了一个“计划录取人数 m”。注意,这里的 m 不是最终录取人数,而是一个计算用的基准值。真正的录取流程是:先把所有选手按成绩从高到低排序,成绩相同的情况下报名号小的人排前面;然后用 m 乘上一个系数算出“第 target 名”的位置,这位选手的成绩就是录取分数线;最后把所有成绩不低于分数线的选手全部录取。
用大白话说就是:不是只取前 m 个,而是会把所有达到线的人都捞进来。这是整道题最容易踩的思维陷阱。举个例子,如果有 6 个选手成绩分别是 95、95、90、88、88、84,计划录取 m=3,那算出来的 target 是 4,第四名的成绩是 88,于是分数线就是 88。可是实际达到 88 分的人有 5 个,所以最终录取 5 人,而不是 3 人。这就是“分数线划定”和“直接取前几名”的本质区别。
1.2 三条经常被忽略的隐藏规则
这道题表面上一共就三件事:排序、算分数线、输出名单。但题目描述里埋了三条很容易被忽略的规则,我当年做这道题时,第一条就差点看漏。
第一条是“同分按报名号升序”。很多同学写排序比较器的时候,只写了一句 return a.score > b.score,结果成绩高的排前面了,同分的顺序却是乱的。这题的数据设计得比较温和,同分的人如果报名号顺序不对,可能只影响输出顺序而不影响判分,但如果你真的想稳过,必须把报名号升序这个条件写进排序规则里。它也是后面我推荐的“双关键字排序”练习点。
第二条是“分数线不是直接算成绩,而是先找排名再取成绩”。有些人会想当然地认为分数线是 m*1.5 名选手的成绩,这没问题,但问题是这个“第 target 名”是排序之后才算的,不是输入顺序里的第几个。也就是说,你至少得先完整排一次序,才能知道分数线。
第三条是“满足分数线的人全部录取,人数可能大于 target”。这一点我刚才用例子解释过,它直接决定了输出第二行的“实际录取人数”不是一个写死的 m 或者 target,而是要在排序后从头往后数一遍,把所有成绩不低于分数线的选手都数进去。数人的时候通常可以顺便把名单输出,因为此时数组已经按规则排好序了。
1.3 它在普及组题目中的位置
NOIP 普及组一般有四道题,难度从入门到进阶递增。P1068 属于整套试卷中比较靠前的位置,前一道往往是更基础的字符串或模拟题,后一道就开始涉及数学和更复杂的算法。这一点其实很有参考价值:它被放在这个位置,说明出题人默认选手已经掌握排序,但还没上升到图论、动态规划这些高难度内容。所以这道题本质是“普及组日常题”的代表——题目里塞了一个现实场景,但核心还是排序,或者说,是“排完序后的一次线性筛选”。
这类题在洛谷上一般会被标记为普及-,和 P1980、P1307、P2669 这些题属于同一难度区间。很多人刷题喜欢直接上难度,但我一直建议把这种普及-的题当成基本功来练,因为它能帮你养成两个习惯:第一,拿到题先读清楚规则,而不是急着写代码;第二,凡是涉及排序的题,先想清楚比较器的每个条件。这两个习惯在后来的比赛里比多背几个算法模板管用得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解题思路拆解:从题意到 O(n log n) 实现
2.1 分数线计算:用整数运算替代浮点
题目里有个经典的表述:把 m 乘以 1.5,再向下取整。很多人的第一反应是写 int target = m * 1.5;,也就是把浮点数强转成整数。这样做在绝大多数情况下没错,因为 C++ 里 double 转 int 会截断小数部分,等于向下取整。但作为一个踩过坑的人,我要说:能用整数运算解决的问题,尽量不要引入浮点数。
为什么?因为浮点数在底层是二进制表示的,1.5 这个数虽然能精确表示,但在实际计算中只要多绕一道运算,比如 m * 1.5 里的乘法,或者后续再参与别的公式,就可能出现精度误差。虽然这道题的数据范围比较小,不会真的出问题,但如果养成习惯,以后遇到更大的数据范围或更复杂的计算,浮点误差就可能是 WA 的来源。
更好的写法是把 m * 1.5 转成整数算式。m 乘 1.5 向下取整,等价于 m * 3 / 2 向下取整,而 m * 3 / 2 在整数除法下就等于 m + m / 2。这里要稍微解释一下:m / 2 在整数除法中本身就会向下取整,所以 m + m / 2 和 m * 1.5 向下取整的结果完全一致。比如 m=5 时,m * 1.5 = 7.5,向下取整是 7,而 m + m / 2 = 5 + 2 = 7,一模一样。这个写法既避免了浮点误差,又让代码看起来干净很多。
2.2 排序规则:双关键字比较器是关键
这一步是整个题的核心。我们要把所有选手按“成绩降序,成绩相同则报名号升序”排列。如果你用 C++ 的 sort,那需要自己写比较器;如果用 Java,需要给 Comparator 写 Lambda 表达式。无论哪种语言,思路都是同一个:先比较成绩,成绩不相等就直接按成绩降序返回;如果成绩相等,再比较报名号,按报名号升序返回。
这里我见过不少人写错,尤其是把两个条件写反,或者只在成绩相等时写了“等于”情况却忘了返回。写比较器的时候心里要清楚,sort 对比较器的要求是“严格弱序”,也就是说,两个元素相等时比较器必须返回 false,而不能返回 true。如果写得模棱两可,轻则排序结果不符合预期,重则在极端数据下可能引发运行时错误。
用生活化的方式理解:这就像一个招生办在排合格名单,先看总分,总分一样就看语文,语文一样看数学,直到比出先后。这里的“报名号”相当于一个附加的排序依据,报名号小的人排前面,相当于“并列时按报名号优先”。竞赛里很多排序题都是这个套路,所以学会写这种双关键字比较器,你以后遇到“按照 xx 排序,如果 xx 相同则按照 yy 排序”这类题,直接就套模板了。
2.3 筛选录取名单:排序后的一次遍历
排好序之后,事情就很简单了。因为数组已经按成绩降序排好了,所以分数线就是第 target 名选手的成绩,也就是下标为 target-1 的那个元素。注意这里要减 1,因为数组下标从 0 开始。用 v[target - 1].score 就能拿到分数线。
拿到分数线之后,从数组开头开始遍历,把所有成绩不低于分数线的选手都计数,同时记录下他们的报名号和成绩,供最后输出。这里有一个优化点:因为数组已经降序排列,所以一旦遇到第一个成绩小于分数线的选手,就可以直接 break 跳出循环,后面的选手成绩只会更低,没有必要继续遍历。这样做既省时间,也让代码的意图更清晰。
有人可能会问,为什么不直接用二分查找来定位分数线?其实也可以,但没必要。这道题的 n 最大也就几千,线性扫描一次完全没压力,而且理解起来更直观。O(n log n) 的排序是主要的耗时部分,后面的 O(n) 扫描几乎可以忽略不计。写算法题不是越花哨越好,在数据范围小的情况下,自然、直接的写法往往更不容易出错。
3. 两种语言的完整实现与提交经验
3.1 C++ 代码与逐段说明
C++ 是竞赛中最常用的语言,洛谷上绝大多数题解也都是 C++ 写的。以下是 P1068 的完整 C++ 实现,我加了注释,方便你对照思路。
cpp复制#include <bits/stdc++.h>
using namespace std;
struct Node {
int id; // 报名号
int score; // 成绩
};
bool cmp(const Node &a, const Node &b) {
if (a.score != b.score) return a.score > b.score; // 成绩高的在前
return a.id < b.id; // 成绩相同,报名号小的在前
}
int main() {
int n, m;
cin >> n >> m;
vector<Node> v(n);
for (int i = 0; i < n; i++) {
cin >> v[i].id >> v[i].score;
}
sort(v.begin(), v.end(), cmp);
int target = m + m / 2; // 等价于 floor(m * 1.5)
int line = v[target - 1].score; // 分数线
int cnt = 0;
while (cnt < n && v[cnt].score >= line) {
cnt++; // 统计所有不低于分数线的选手
}
cout << line << " " << cnt << endl;
for (int i = 0; i < cnt; i++) {
cout << v[i].id << " " << v[i].score << endl;
}
return 0;
}
这段代码有几个值得留意的细节。第一,target = m + m / 2 用的是整数除法,前面说过,这比 int(m * 1.5) 更稳。第二,while (cnt < n && v[cnt].score >= line) 这个写法很安全,既保证了不越界,又能在扫描到第一个低于分数线的选手时停下来。第三,比较器写在结构体外面,用普通函数的形式传入 sort,结构清晰,也方便扩展成更多关键字的排序。
3.2 Java 写法和洛谷提交注意事项
如果你更喜欢用 Java 刷题,这道题同样能写,而且代码结构差别不大。洛谷支持 Java 提交,但有一个非常关键的注意事项:类名必须是 Main,否则会编译错误。这是很多第一次在洛谷用 Java 提交的人必踩的坑。
java复制import java.util.*;
public class Main {
static class Node {
int id;
int score;
Node(int id, int score) {
this.id = id;
this.score = score;
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
Node[] arr = new Node[n];
for (int i = 0; i < n; i++) {
arr[i] = new Node(sc.nextInt(), sc.nextInt());
}
Arrays.sort(arr, (a, b) -> {
if (a.score != b.score) return b.score - a.score;
return a.id - b.id;
});
int target = m + m / 2;
int line = arr[target - 1].score;
int cnt = 0;
while (cnt < n && arr[cnt].score >= line) {
cnt++;
}
System.out.println(line + " " + cnt);
for (int i = 0; i < cnt; i++) {
System.out.println(arr[i].id + " " + arr[i].score);
}
}
}
在洛谷提交时,语言下拉框要选 Java 8 或对应版本,然后在代码框里黏贴上面的内容。有人会问“洛谷如何切换编程语言”,其实很简单:在提交页面的代码框上方或下方有个语言选择下拉框,点击后选择 Java 再提交就行。很多人在本地用 IntelliJ 或 Eclipse 写习惯了,类名随便起,结果一提交到洛谷就编译错误,就是因为没把类名改成 Main。
另外,Java 的 Scanner 在这道题的数据范围下完全够用,不需要刻意用 BufferedReader 优化。如果以后遇到数据量特别大的题,再考虑换成更快的输入方式即可。毕竟这道题 n 最大也就几千,用 System.out.println 输出同样没问题。
3.3 两种写法对比与选型建议
我整理了一个简单对比表,方便你根据自己情况选择语言。
| 对比维度 | C++ 写法 | Java 写法 |
|---|---|---|
| 核心依赖 | sort + 函数比较器 | Arrays.sort + Lambda 比较器 |
| 提交注意 | 无特殊类名要求 | 类名必须为 Main |
| 输入输出 | cin/cout 或 scanf/printf | Scanner / System.out |
| 运行速度 | 通常更快,STL 排序效率高 | 会慢一点,但这题几乎无差别 |
| 代码风格 | 偏竞赛,简洁直接 | 更贴近工程写法,结构清晰 |
| 适合人群 | 竞赛选手、习惯 C++ 的开发者 | 熟悉 Java、用 Java 刷题的人 |
如果让我给建议:如果你刚开始准备 NOIP,建议直接练 C++,因为普及组到提高组的比赛环境对 C++ 的支持最成熟,网上题解也大多以 C++ 为主,方便对照学习。如果你只是日常练手,想保持 Java 手感,那用 Java 刷这道题也完全没问题,重点是掌握排序思路,而不是纠结语言。
4. 新手最容易踩的五个坑与排查实录
4.1 浮点数取整:为什么 m + m / 2 比 int(m * 1.5) 更稳
第一个坑我之前已经提过,就是 int(m * 1.5)。虽然这道题的数据范围下通常不会出错,但我不建议你养成“能用浮点就用浮点”的习惯。计算机里的浮点数在表示 1.5、2.5 这样的数时没问题,但一旦参与复杂的乘除或与其他数值相加,就可能出现 0.999999 变成 0 的情况。虽然 int(1.5 * 3) 是 4,但如果你换成一个更复杂的表达式,比如 int(1.5 * m) - 1,在某些边界条件下就可能出错。
从数学上看,floor(m * 1.5) 和 m + m / 2 是恒等的,因为 m * 1.5 等于 m * 3 / 2,整数除法本来就会自动向下取整。所以遇到这种“乘以一个小数再取整”的描述,第一反应应该是化成整数运算,而不是顺手写个浮点强转。这一点在很多题目里都是通用技巧,包括以后遇到求中位数、求百分比等场景,都尽量用整数运算,既快又稳。
4.2 排序漏掉第二关键字
第二个坑是排序规则不完整。我在给群友 debug 时,遇到过好几份只写了 return a.score > b.score; 的代码。这种代码在题目给的样例上可能能过,因为样例里同分的人恰好顺序正确。但一旦评测数据里出现成绩相同且报名号顺序与输入顺序不同的情况,输出的名单顺序就错了,直接导致 WA。
怎么排查这个问题?很简单,自己造一组带同分的数据,比如 3 个选手,两个都是 95 分,报名号分别是 3000 和 2000,然后看输出是不是按 2000、3000 的顺序排。如果发现排序后顺序没变,那一定是比较器漏了第二关键字。这种排查方法对以后所有排序题都有效。
4.3 输出格式与边界处理
第三个坑是输出格式。题目要求第一行输出分数线和实际录取人数,中间用空格隔开;后面每一行输出一个选手的报名号和成绩,也是空格隔开。这个格式本身不难,但容易在边界条件上出事。比如,实际录取人数 cnt 大于 target 时,你要按排序后的完整名单输出,而不是只输出 target 个人。又比如,分数线可能是 0 分,所有选手都被录取,此时 cnt 等于 n,你要确保循环把所有人都输出完。
边界情况里还有一个隐藏点:target - 1 这个下标。如果 m 是 1,target 就是 1,target - 1 是 0,对应数组第一个元素,没问题。但如果 n 本身小于 target,也就是选手总数比计划录取的基准还少,取第 target 名就会越界。实际上题目保证 n 不小于 m,所以正常不会出问题,但写代码时心里要有个数,避免在别的地方复用同一个计算时多减了一个 1。
4.4 本地正常提交却 WA 的常见原因
第四类问题很让人头疼:本地跑样例完全正确,一提交到洛谷就 WA。常见原因有三个。第一,洛谷的评测环境可能用的是多组测试数据,有些题不是只读一次输入,而是要用 while (cin >> n >> m) 循环读,P1068 是单组数据,但其他题不一定,所以看题要仔细。第二,C++ 选手如果用了 scanf 却忘了 #include <cstdio>,本地可能因为某些编译器的宽松处理没报错,但换到洛谷的编译环境就出问题。第三,也是最常见的,本地调试时在代码里留了 cout << "debug" 之类的输出,忘了删掉,提交后多出来许多空白行,即使答案本身正确,输出格式也会被判错。
我在线下带练习时经常提醒学生:提交前把代码里所有调试输出删干净,尤其别用 cout << v[i] << endl; 打印中间结果后忘了注释。这套习惯用多了,你会发现自己提交一次通过的几率大大提高。
4.5 用样例和自己造的数据做验证
最后一个建议是给自己造数据。题目给出的样例一般是最基本的情况,只能验证主流程,不能覆盖所有边界。我通常会额外造三组数据:第一组是全员同分,这样能验证报名号的排序是否生效,以及最后的 cnt 是否等于 n;第二组是 m 等于 n 的情况,看分数线会不会偏低,所有人的成绩是否都高于等于线;第三组是 m 特别小,比如 m=1,这时候 target 等于 1,要确保程序不会因为 target - 1 的边界问题出异常。这三组数据跑下来,这道题基本就稳了。
如果你不想每次手工造数据,也可以直接把样例输入复制到本地运行,然后对照标准输出。但在正式比赛或考试里,没有标准输出可以对照,所以学会自己构造测试数据是一个非常重要的基本功。
5. 从 P1068 延伸出去:基础排序题的通用套路
5.1 读题能力比编码能力更容易丢分
很多人做这道题出错,并不是因为不会写 sort,而是因为没有读懂规则。比如把“实际录取人数”当成 m 处理,或者以为分数线就是 m*1.5 名选手的成绩但不去排序,或者没有意识到“成绩不低于分数线”这个条件包含了同分选手。这些错误的共同点,都是对题目的文字理解出现了偏差。
我一直觉得,算法竞赛里最核心的能力不是背诵模板,而是把自然语言描述转换成精确的逻辑规则。以 P1068 为例,题目里“如果成绩相同,则报名号小的排在前面”这句话,翻译成比较器就是很直接的代码。但如果你读题时没注意到这句话,那代码怎么写都是错的。日常练习中,我建议把每道题的关键条件用笔写下来,尤其是带“如果…就…”这类结构的句子,然后逐条检查代码有没有实现到位。
5.2 排序题的三步套路
做多了你会发现,P1068 这类题有一个几乎固定的三步套路,可以套用到很多排序应用题上。
第一步,确定排序规则。根据题目描述,找出主关键字和次关键字,主关键字决定主要顺序,次关键字在主关键字相同时发挥作用。把这一条写清楚,比较器就完成了一半。
第二步,确定排序之后要做的事。有的题排序后取前 K 个,有的题排序后找符合条件的区间,有的题排序后去重。P1068 是排序后找所有不低于分数线的连续区间,因为数组已经有序,从前往后扫即可。
第三步,确定输出格式和边界条件。输出的是完整名单还是部分名单?是否需要额外计数?最大数据范围会不会导致 int 溢出?把这些边界条件想清楚,代码就不会在细节上翻车。
这套套路不仅适用于竞赛,在工作中写业务代码时也一样。凡是“对一组数据按某种规则排序,再按某个条件筛选”的需求,本质上都是这个思路。
5.3 同系列基础题推荐
如果你刷完 P1068 还想再练几道类似的题,洛谷上有一批难度相近的普及组题值得做。P1980《计数问题》是入门难度,考察数字拆解和计数,适合新手练循环和分支。P1307《数字反转》同样是入门题,考的是整数处理和边界情况,特别是负数和末尾零的处理。P2669《金币》是一个模拟类题目,考的是按规则逐天累加,适合练手。这几道题的共同点是都不需要复杂算法,但对读题和细节要求很高,正好能帮你巩固刷题的基本功。
做这些题时,建议你刻意练习我上面说的“先读题、再建模、后编码”的顺序,而不是一拿到题就急着敲键盘。坚持一段时间,你会发现自己的通过率和写代码的信心都会明显提升。
我个人在实际操作中的体会是,P1068 这种普及-难度的题目,最大的价值不是让你炫技,而是夯实基础。每一次 WA 都值得珍惜,因为它暴露的是你读题、建模或编码中某一个具体的漏洞。把这套“排序 + 筛选”的流程吃透,你会发现在更复杂的问题里,很多思路都是从这种简单题迁移过来的。最后再分享一个小技巧:遇到任何需要从排名反推分数线的题,先问自己三个问题——排完序没有?分数线对应的是第几个位置?同分的人要不要全部保留?想清楚这三点,你至少能在这种题上少踩一半的坑。
