题单 · 习题解析

洛谷 P1068 [NOIP 2009 普及组] 分数线划定

★ 顺手查了「别用 1.5 会掉精度」那条流传很广的提醒 —— 这道题上一次都用不上,而 0.7 从 m = 90 就错

原题:洛谷 P1068出自 第 10 章 排序:冒泡 → 归并 → 快排 的题单题面本地存档:2026-08-27
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1068,日期见页头。两边不一致时信原站。

题目描述

世博会志愿者的选拔工作正在 A 市如火如荼的进行。为了选拔最合适的人才,A 市对所有报名的选手 进行了笔试,笔试分数达到面试分数线的选手方可进入面试。面试分数线根据计划录取人数的 150% 划定, 即如果计划录取 m 名志愿者,则面试分数线为排名m × 150%(向下取整)名的选手的分数, 而最终进入面试的选手为笔试成绩不低于面试分数线的所有选手。

现在就请你编写程序划定面试分数线,并输出所有进入面试的选手的报名号和笔试成绩。

输入格式

第一行,两个整数 n, m5 ≤ n ≤ 50003 ≤ m ≤ n),中间用一个空格隔开, 其中 n 表示报名参加笔试的选手总数,m 表示计划录取的志愿者人数。 输入数据保证 m × 150% 向下取整后小于等于 n

第二行到第 n+1 行,每行包括两个整数,中间用一个空格隔开,分别是选手的报名号 k1000 ≤ k ≤ 9999)和该选手的笔试成绩 s1 ≤ s ≤ 100)。数据保证选手的报名号各不相同。

输出格式

第一行,有 2 个整数,用一个空格隔开,第一个整数表示面试分数线;第二个整数为进入面试的选手的 实际人数

从第二行开始,每行包含 2 个整数,中间用一个空格隔开,分别表示进入面试的选手的报名号和笔试成绩, 按照笔试成绩从高到低输出,如果成绩相同,则按报名号由小到大的顺序输出

说明 / 提示

【样例说明】m × 150% = 3 × 150% = 4.5,向下取整后为 4。保证 4 个人进入面试的分数线为 88, 但因为 88 有重分,所以所有成绩大于等于 88 的选手都可以进入面试,故最终有 5 个人进入面试。

NOIP 2009 普及组 第二题。

输入输出样例

输入

6 3
1000 90
3239 88
2390 95
7231 84
1005 95
1001 88

输出

88 5
1005 95
2390 95
1000 90
1001 88
3239 88

样例说明里那段话就是这道题的全部难点 —— 算出来 4 个名额, 可第 4 名那个 88 分有并列,于是最后进去 5 个人。

1★ 这道题的难点全写在样例说明里

★ 三件事,缺一不可

分数线 = 排名第 ⌊m × 150%⌋ 名那个人的分数

② 最终进面试的是所有分数 ≥ 分数线的人 —— 人数可能比 ⌊1.5m⌋

③ 排序是双关键字:成绩从高到低,成绩相同时报名号从小到大。

⇒ 第 ② 条是全部难点,而它在题面和样例说明里各写了一遍。 (第 8 章 P2249 那页说过「第一步永远是跑样例」—— 这道题还要再加一句:样例说明也要读完。)

p1068.cpp★ 这一版就能 AC
// P1068 分数线划定 —— ★ 这一版就能 AC
//
// 三件事,缺一不可:
// ① 分数线 = 排名第 `⌊m × 150%⌋` 名那个人的**分数**;
// ② 最终进面试的是**所有分数 >= 分数线**的人 —— 人数可能比 ⌊1.5m⌋ **多**
// (样例就是:算出来 4 个,因为第 4 名那个 88 分有并列,最后 5 个人进);
// ③ 排序是**双关键字**:成绩从高到低,成绩相同时报名号从小到大。
//
// ⚠ 第 ② 条是这道题的全部难点,而它在样例里就写着 —— **样例说明专门解释了这件事**。
// ⇒ 又一次「第一步永远是把样例读完」(第 8 章 P2249 那页那条)。
//
// ★ `m * 3 / 2` 和 `(int)(m * 1.5)` 在这道题上**完全等价**,一次都不会差
// —— 网上常见的那条「别用 1.5,会掉精度」的提醒,主语错了,见 p1068Count.cpp。
#include <bits/stdc++.h>
using namespace std;
struct Player { int id, score; };
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<Player> v(n);
for (int i = 0; i < n; i++) cin >> v[i].id >> v[i].score;
sort(v.begin(), v.end(), [](const Player& a, const Player& b) {
if (a.score != b.score) return a.score > b.score; // 成绩高的在前
return a.id < b.id; // ③ 成绩相同,报名号小的在前
});
int line = v[m * 3 / 2 - 1].score; // ① 第 ⌊1.5m⌋ 名的分数(下标从 0 起)
int cnt = 0;
while (cnt < n && v[cnt].score >= line) cnt++; // ② 所有 >= 分数线的都算
cout << line << ' ' << cnt << '\n';
for (int i = 0; i < cnt; i++) cout << v[i].id << ' ' << v[i].score << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2第一个 WA:只取前 ⌊1.5m⌋ 名

p1068Cut.cpp⚠ 会 WA 的
// ⚠ 故意写错的:只取前 ⌊m × 150%⌋ 名,没管并列
//
// int cnt = m * 3 / 2; ← 正解还要往后走,把所有「分数 >= 分数线」的都收进来
//
// ★ 它**样例就挂**:样例算出来分数线是 88、名额 4 个,
// 可 88 分有两个人(1001 和 3239),题面明说这两个都进 ⇒ 正确答案是 **5** 个人。
//
// ⚠ 值得注意的是它错得**很轻**:分数线那个数是对的,前 4 行也一字不差,
// 只是少了最后一行、而且人数少了 1。
// ⇒ 这种「只差一行」的错,肉眼扫一遍输出很容易放过去 —— 逐字节比才看得出来。
#include <bits/stdc++.h>
using namespace std;
struct Player { int id, score; };
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<Player> v(n);
for (int i = 0; i < n; i++) cin >> v[i].id >> v[i].score;
sort(v.begin(), v.end(), [](const Player& a, const Player& b) {
if (a.score != b.score) return a.score > b.score;
return a.id < b.id;
});
int cnt = m * 3 / 2; // ⚠ 就是这一行
int line = v[cnt - 1].score;
cout << line << ' ' << cnt << '\n';
for (int i = 0; i < cnt; i++) cout << v[i].id << ' ' << v[i].score << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 它样例就挂,但错得「只差一行」

它输出 88 4 和 4 行,正确答案是 88 5 和 5 行。

★ 注意它错得多轻:分数线那个数是对的,前 4 行一字不差, 只是人数少了 1、少了最后一行。 ⇒ 这种错,肉眼扫一眼输出很容易放过去 —— 逐字节比才看得出来

3第二个 WA:cmp 漏了第二关键字

p1068NoTie.cpp⚠ 会 WA 的
// ⚠ 故意写错的:cmp 只写了成绩,漏掉第二关键字(报名号)
//
// return a.score > b.score; ← 成绩相同时,谁在前面就**不确定**了
//
// ★ 这一版的诡异之处:它**不一定错**。`std::sort` 不保证稳定,
// 但也没规定并列的一定会乱 —— 数据小的时候常常「碰巧」是对的。
// ⇒ 于是它是那种「本地样例过了、交上去 WA」的典型。
//
// ⚠ 更要命的是:**换一台机器、换一个编译器版本,它的输出可能就变了**
// (`sort` 的实现细节不同)。⇒ 这类 bug 连「复现」都不保证。
//
// ★ 抓它要靠**大量同分**的数据(见 p1068Gen.cpp 的档位 1)。
#include <bits/stdc++.h>
using namespace std;
struct Player { int id, score; };
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<Player> v(n);
for (int i = 0; i < n; i++) cin >> v[i].id >> v[i].score;
sort(v.begin(), v.end(), [](const Player& a, const Player& b) {
return a.score > b.score; // ⚠ 少了 id 那一句
});
int line = v[m * 3 / 2 - 1].score;
int cnt = 0;
while (cnt < n && v[cnt].score >= line) cnt++;
cout << line << ' ' << cnt << '\n';
for (int i = 0; i < cnt; i++) cout << v[i].id << ' ' << v[i].score << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 它比上一个更难缠:错法**不确定**

std::sort 不保证稳定 —— 成绩相同的两个人谁排前面,标准没规定。 本页这一版在样例上把两对并列都排反了,但那是这台机器这个标准库的行为: 换个编译器版本、换个数据规模,输出可能就变了。

⇒ 「本地样例过了、交上去 WA」的典型;更糟的是它连复现都不保证。 ★ 所以这一页的断言只钉「和正解不一致」,不钉它具体输出了什么 (第 9 章 P1182 那页对 int 溢出也是这么处理的: 实现相关的具体值不写进断言)。

4★★★ 顺便查一条广为流传的提醒:「别用 1.5,会掉精度」

网上题解里常见这么一句:m × 150% 要写成 m * 3 / 2,别写 (int)(m * 1.5),浮点会掉精度。

听起来很有道理。扫一遍看看:

p1068Count.cpp扫一遍取整
// 「别用 1.5,会掉精度」—— 这条提醒到底对不对?扫一遍
//
// 用法:./p1068Count 人话版
// ./p1068Count csv 只打 `键,值`,给 check:viz 用
//
// 网上题解常见的提醒是:「`m * 150%` 要写成 `m * 3 / 2`,别写 `(int)(m * 1.5)`,
// 浮点会掉精度」。听起来很有道理 —— **但这道题上它一次都不会错**。
//
// 这份程序做两件事:
// ① 在题面的范围 `m ∈ [3, 5000]` 里,把三种写法逐个比过去;
// ② 换几个别的倍数做对照,看「掉精度」到底什么时候真的发生。
//
// ★ 结论先写在这儿(下面是跑出来的):**危险的不是「用了浮点」,
// 是「那个小数在二进制里做乘法之后,会不会正好跨过一个整数边界」** ——
// 而这既不等于「是不是有限二进制小数」,也不能靠直觉,只能算或者扫。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
bool csv = (argc > 1 && string(argv[1]) == "csv");
/* ① 题面范围内的三种写法 */
int bad = 0, firstBad = -1;
for (int m = 3; m <= 5000; m++) {
int a = (int)(m * 1.5), b = m * 3 / 2, c = m * 150 / 100;
if (a != b || b != c) { bad++; if (firstBad < 0) firstBad = m; }
}
/* ② 对照:几个不同的倍数,各在 [1, 10⁶] 里错多少次 */
struct Case { const char* name; double f; long long num, den; };
Case cs[] = {
{ "1.5", 1.5, 3, 2 }, { "1.25", 1.25, 5, 4 },
{ "1.1", 1.1, 11, 10 }, { "0.9", 0.9, 9, 10 }, { "0.7", 0.7, 7, 10 },
};
long long cnt[5]; long long first[5];
for (int i = 0; i < 5; i++) {
cnt[i] = 0; first[i] = -1;
for (long long m = 1; m <= 1000000; m++) {
long long x = (long long)(m * cs[i].f), y = m * cs[i].num / cs[i].den;
if (x != y) { cnt[i]++; if (first[i] < 0) first[i] = m; }
}
}
if (csv) {
printf("inRange,%d\n", bad);
for (int i = 0; i < 5; i++) printf("bad_%s,%lld\nfirst_%s,%lld\n", cs[i].name, cnt[i], cs[i].name, first[i]);
return 0;
}
printf("① 题面范围 m ∈ [3, 5000]:(int)(m*1.5) / m*3/2 / m*150/100 三者不一致 **%d** 次\n", bad);
printf(" ⇒ 那条「别用 1.5」的提醒,在这道题上**一次都用不上**。\n\n");
printf("② 换几个倍数,在 m ∈ [1, 10⁶] 里各错多少次:\n\n");
for (int i = 0; i < 5; i++) {
printf(" ×%-5s 不一致 %8lld 次", cs[i].name, cnt[i]);
if (first[i] > 0) printf(",第一个 m = %lld", first[i]);
printf("\n");
}
printf("\n ★ 1.5 和 1.25 是**二进制精确**的(3/2、5/4)—— 乘出来一位不差,当然不会错。\n");
printf(" ★★ 可 1.1 和 0.9 **并不精确**,却也一次没错 —— 「不精确」不等于「会出事」。\n");
printf(" ★★★ 只有 0.7 真的错了,而且从 m = %lld 就开始。\n", first[4]);
printf(" ⇒ 判断依据不是「是不是浮点」,也不是「精不精确」,\n");
printf(" 是「乘完之后会不会正好落在整数边界的另一侧」—— 这只能算,或者像这样扫一遍。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 在这道题上,那条提醒一次都用不上

题面范围 m ∈ [3, 5000],三种写法 (int)(m*1.5) / m*3/2 / m*150/100 逐个比过去,不一致 0 次

原因很干脆:1.5 在二进制里是精确的(就是 3/2,等于「加一半」), 只要 3m 没超过 2⁵³,乘出来一位不差。

★★ 可事情还有另一半 —— 换几个倍数在 m ∈ [1, 10⁶] 里扫:

倍数 二进制里精确吗 取整不一致的次数
×1.53/2 精确 0
×1.255/4 精确 0
×1.111/10 不精确 0
×0.99/10 不精确 0
×0.77/10 ⚠ 不精确 18 719,第一个 m = 90

1.10.9 都不精确,却一次都没错。

⇒ 所以判断依据既不是「是不是用了浮点」,也不是「那个小数精不精确」, 而是「乘完之后会不会正好落在整数边界的另一侧」—— 这件事只能,或者像上面这样扫一遍

★ 一般化:别把「听起来有道理的提醒」当结论用。 它可能是对的(0.7 就是反例),但它的适用范围往往比说的窄得多。

5★ 对拍:三个档位

p1068Brute.cpp参照物:选择排序
// P1068 的对拍参照物:不用 sort,用**选择排序**自己排一遍
//
// ★ 它和正解**没有共用任何一行排序代码** —— 正解用 `std::sort` + 自定义 cmp,
// 这里是最笨的双重循环,每次挑出「成绩最高、并列时报名号最小」的那个。
// ⇒ 两边一起错的概率极低(第 7 章 P1147 那条:验算要走一条无关的路)。
//
// ⚠ 顺带:选择排序**不稳定**,但这里根本不需要稳定 ——
// 因为 cmp 已经把「成绩 + 报名号」定死了,任何两个人都分得出先后,
// 排出来的顺序是**唯一**的。⇒ 「要不要稳定排序」这个问题,
// 在「关键字能把所有元素两两分开」时根本不存在(P1104 那页才是它真的要紧的地方)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<int> id(n), sc(n);
for (int i = 0; i < n; i++) cin >> id[i] >> sc[i];
vector<bool> used(n, false);
vector<int> ord;
for (int k = 0; k < n; k++) {
int best = -1;
for (int i = 0; i < n; i++) {
if (used[i]) continue;
if (best < 0 || sc[i] > sc[best] || (sc[i] == sc[best] && id[i] < id[best])) best = i;
}
used[best] = true;
ord.push_back(best);
}
int line = sc[ord[m * 3 / 2 - 1]];
int cnt = 0;
while (cnt < n && sc[ord[cnt]] >= line) cnt++;
cout << line << ' ' << cnt << '\n';
for (int i = 0; i < cnt; i++) cout << id[ord[i]] << ' ' << sc[ord[i]] << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1068Gen.cpp生成器:三个档位
// 数据生成器(P1068 对拍用):`./p1068Gen <seed> [level]`
//
// level 0(默认)成绩随机取 [1,100] —— 兜底
// level 1 ★ **大量同分**:成绩只在 3 个值里取
// ⇒ 专抓 p1068NoTie(cmp 漏了报名号)和 p1068Cut(没算并列)
// level 2 ★ **分数线上正好有一堆并列**:先随机造,再把第 ⌊1.5m⌋ 名前后的分数抹平
// ⇒ 专抓 p1068Cut
//
// ⚠ 题面:5 <= n <= 5000,3 <= m <= n,报名号 k ∈ [1000, 9999] 且**互不相同**,
// 成绩 s ∈ [1, 100],并且保证 ⌊1.5m⌋ <= n。
// ★ 报名号互不相同这一条必须守住 —— 否则「并列时按报名号排」就没有唯一答案了,
// 两个程序会「一致地输出垃圾」。
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng;
static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) {
rng.seed((argc > 1) ? (unsigned)atoi(argv[1]) : 1);
int level = (argc > 2) ? atoi(argv[2]) : 0;
int n = ri(5, 30);
int m = ri(3, max(3, (n * 2) / 3)); // 保证 ⌊1.5m⌋ <= n
while (m * 3 / 2 > n) m--;
/* 报名号互不相同 */
vector<int> ids;
{
set<int> s;
while ((int)s.size() < n) s.insert(ri(1000, 9999));
ids.assign(s.begin(), s.end());
for (int i = n - 1; i > 0; i--) swap(ids[i], ids[ri(0, i)]);
}
vector<int> sc(n);
for (int i = 0; i < n; i++) sc[i] = (level == 1) ? ri(1, 3) * 10 : ri(1, 100);
if (level == 2) {
/* 把分数线附近抹平:先排个序找出第 ⌊1.5m⌋ 名的分数,再把一批人都设成它 */
vector<int> t = sc;
sort(t.rbegin(), t.rend());
int line = t[m * 3 / 2 - 1];
for (int i = 0; i < n; i++) if (ri(0, 2) == 0) sc[i] = line;
}
printf("%d %d\n", n, m);
for (int i = 0; i < n; i++) printf("%d %d\n", ids[i], sc[i]);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

每档 300 轮,和参照物不一致的轮数

档位 正解 ≡ 参照物 Cut 没算并列 NoTie 漏关键字
level 0 成绩随机取 [1,100] 300 / 300 32 117
level 1 ★ 成绩只有 3 个值 300 / 300 230 297
level 2 分数线上一堆并列 300 / 300 228 282
★ 又一次「密度才是覆盖能力」

level 0 照题面随机(成绩 1~100),Cut 只抓 32/300 —— 因为 100 种成绩、几十个人,第 ⌊1.5m⌋ 名正好有并列的概率不高。

把成绩压到只有 3 个值,抓获数直接跳到 230/300

⇒ 和第 7 章 P1102 那条是同一件事: 抓不抓得到,看的是「人数 / 值域」的比值,不是「数据大不大」。 ★ 有意思的是 level 2(专门在分数线上造并列)并不比 level 1 更强(228 vs 230)—— 把值域压小这个「笨办法」,效果和「精心构造」一样好。

6一张总表

版本 错在哪 样例 对拍(900 轮) 结果
p1068 —— 900 / 900 AC
p1068Cut 没算分数线上的并列 490 ✗ WA
p1068NoTie cmp 漏了报名号 696 ✗ WA(而且不确定)
这一页记住三句话
  1. ★★ 样例说明也是题面。 这道题的全部难点(分数线上的并列要全进) 在题面和样例说明里各写了一遍,两处都跳过去才会写出第 ② 步那一版。
  2. ★★★ 别把「听起来有道理的提醒」当结论用。 「别用 1.5 会掉精度」在这道题上一次都用不上(1.5 是二进制精确的); 而真正会出事的 0.7,从 m = 90 就开始错。判断依据只能靠算或者扫。
  3. 实现相关的错,只钉「不一致」,不钉具体输出。 sort 对并列元素的顺序标准没规定 —— 换个编译器它可能就变了。