ACM 一个月学习计划 · DAY 22

Day 22 · 二分查找:在有序里快速定位

每次砍掉一半——这是除了「预处理」之外,竞赛里最值钱的提速手段。今天的目标是:写出一个不会死循环、不会差一位的二分

今日目标:能手写不出错的二分,会用并讲清 lower_bound / upper_bound 的语义与边界
预计用时:约 3.5 小时(含做题)  难度:入门 ~ 普及/提高-
题目来源:洛谷 P2249 / P1102 / P1678 / P1024
← Day 21 · BFS 进阶 + 第二/三周复盘

⏱今日时间安排

0 - 20 min搞懂二分的前提和「为什么这么快」(第 1 节)
20 - 45 minSTL 三件套:lower_bound / upper_bound / 个数(第 2 节)
45 - 90 min手写二分与边界细节——今天的重头戏(第 3 节)
90 - 115 min实数二分与 P1024 的扫描思路(第 4 节)
115 - 205 min做 4 道题,每题限时 30 分钟
205 - 230 min对照本文题解,把没做出来的补掉
230 - 245 min写题解笔记 + 做自测题
💡 昨天内容的衔接

昨天(Day 21 的 P1141)我们用「预处理换时间」把 10⁵ 次询问从 10¹¹ 次操作压到 10⁶ 次。

今天换一种思路:不提前算,而是每次查询时只用 20 次比较就在 100 万个有序数里定位。核心手段是「每次砍掉一半」。

两种手段你会经常一起用:先排序 → 再二分,就是竞赛里最常见的两行组合拳。

1二分的前提:单调性

先想一个猜数游戏

我心里想一个 1 到 1000 之间的整数,你来猜,我只会告诉你「大了」「小了」「对了」。

最笨的猜法:1、2、3、4…… 最坏要猜 1000 次。
聪明的猜法:先猜 500,再猜 750 或 250,再猜中间……最多 10 次一定能猜对。因为 2¹⁰ = 1024 > 1000。

🎯 这就是二分

每问一次,就把「可能的范围」砍掉一半。

能这么干的前提是:这个范围是有序的——猜大了,所有比它大的数就都能一次性排除掉。

为什么叫「二分查找」而不是「随便砍」

之所以每次都砍正中间,是因为「每次砍掉一半」能让轮数最少。砍掉了 n 的一半、四分之一、八分之一……多少次才能砍到 1 个?答案是 ⌈log₂n⌉ 次。

数据规模 n线性查找:最多比较几次二分查找:最多比较几次快了多少倍
1001007约 14 倍
10⁴10 00014约 700 倍
10⁶1 000 00020约 5 万倍
10⁹1 000 000 00030约 3300 万倍

记两个数就够用:n = 10⁶ 时约 20 次,n = 10⁹ 时约 30 次。因为它们对应 2²⁰ ≈ 10⁶、2³⁰ ≈ 10⁹。

⚠️ 前提不能忘:必须有序(或具有单调性)

乱序的数组上二分,得到的答案是随机的。没有任何意义。

所以看到二分的题目,第一步永远是:数组排序了吗?没有就先 sort。

排序是 O(n log n),二分是 O(log n)。如果只查几次,排序反而更贵;但如果是 P2249、P1678 这种「一次排序 + 很多次查询」,就非常划算。

「单调性」比「有序」更宽

二分真正需要的不是「数组排好序」,而是「一边全不满足,另一边全满足」这种可以一刀切的性质。

比如「第一个 ≥ x 的位置」:它左边的数全都 < x,它右边的数(含自己)全都 ≥ x,中间有一条清晰的分界线。这种性质叫单调性,也是明天「二分答案」的基础。

2STL 三件套:先用熟,再手写

C++ 的 <algorithm> 里已经把二分写好了。比赛时优先用它们——不会写错,不会死循环。手写二分主要是为了理解原理,以及应付需要改造的场景。

两个函数的语义

函数含义找不到时返回
lower_bound(a, a+n, x)第一个 ≥ x 的位置(lower = 下界)n(越界的开区间位置)
upper_bound(a, a+n, x)第一个 > x 的位置(upper = 上界)n

注意它们返回的是指针,要减掉数组起始地址才变成下标:

#include <algorithm>
int a[100005];

int pos  = lower_bound(a, a + n, x) - a;    // 第一个 >= x 的下标
int pos2 = upper_bound(a, a + n, x) - a;    // 第一个 >  x 的下标
int cnt  = pos2 - pos;                       // ★ x 在数组里出现了几次
⚠️ 最容易踩的坑:lower_bound 不保证「找到了」

lower_bound 只保证 a[pos] >= x,不保证 a[pos] == x!

数组是 1 3 5,你查 4:lower_bound 返回下标 2(因为 a[2] = 5 ≥ 4),但 4 根本不在数组里。

所以「x 存在吗」必须自己再验一次:

int pos = lower_bound(a, a + n, x) - a;
bool exist = (pos < n && a[pos] == x);      // ★ 两句都不能少

P2249 就栽在这个点上——它要求「找不到输出 −1」。

为什么「个数 = upper_bound − lower_bound」

数组排好序之后,所有等于 x 的数一定是挨在一起的一段连续区间(这是有序才有的性质)。

拿 P2249 的样例数组举例(下标从 0 开始):

0
1
1
3
2
3
3
3
4
5
5
7
6
9

查 x = 3:

🎯 一句话记住

lower_bound 是这段的「左端点」,upper_bound 是「右端点再往右一格」,相减就是长度。

如果 x 不在数组里,两者会指向同一个位置,相减等于 0 —— 这也是一个「x 存不存在」的判据。

3★ 手写二分:两种写法与边界细节

这一节是今天最重要的部分。二分的所有 bug 都来自「区间开闭」和「mid 取整」这两件事。

写法一:闭区间 + while(l <= r) + 用 ans 记录(推荐新手用)

目标:找第一个 ≥ x 的位置(就是手写版的 lower_bound)。

int l = 0, r = n - 1, ans = -1;     // 闭区间 [l, r] 里找
while(l <= r){                      // ★ 区间里还有元素就继续
    int mid = l + (r - l) / 2;      // 防溢出的中点写法
    if(a[mid] >= x){
        ans = mid;                  // ★ 先记下来(它可能是答案)
        r = mid - 1;                //   但左边可能还有更靠前的,继续往左找
    } else {
        l = mid + 1;                //   a[mid] < x,mid 和它左边都不可能是答案
    }
}

逐句解释

代码含义
l = 0, r = n - 1闭区间 [l, r],两端都是可能藏着答案的合法下标
while(l <= r)区间 [l, r] 还有元素(l ≤ r)就继续;l > r 说明区间空了,结束
ans = -1初值表示「还没找到」。因为要求「第一个」,所以命中后不能直接返回
r = mid - 1a[mid] ≥ x 已经满足了,但左边可能还有更靠前的 ≥ x 的数,所以连 mid 一起排除到右边,去 [l, mid-1] 里继续
l = mid + 1a[mid] < x,那么 [l, mid] 里全都 < x,整个扔掉,去 [mid+1, r] 里找
✅ 这种写法为什么不会死循环

每一轮区间都会严格缩小:要么 r 变成 mid-1(比原来的 r 小),要么 l 变成 mid+1(比原来的 l 大)。区间长度每轮至少减 1,迟早会到 0。

诀窍就是:收缩时永远用 mid ± 1,不要用 mid 本身。

写法二:半开区间 + while(l < r) + 直接返回 l

另一种常见写法,不需要 ans 变量,最后 l 就是答案:

int l = 0, r = n;                   // ★ 注意是 n,不是 n-1!
while(l < r){                       // ★ 区间空了(l == r)就停
    int mid = (l + r) / 2;
    if(a[mid] >= x) r = mid;        // ★ mid 可能是答案,所以 r = mid(不能减 1)
    else            l = mid + 1;    //   mid 一定不是答案,所以 +1
}
return l;                           // l == r,就是第一个 >= x 的位置
                                    // 返回 n 表示「所有数都 < x」,也就是没找到
代码含义
r = n半开区间 [l, r)。r 是「答案不可能到达的位置」,所以答案可以是 0 ~ n,一共 n+1 种可能,初值取 n
while(l < r)[l, r) 里有元素当且仅当 l < r。l == r 时区间为空
r = mida[mid] ≥ x,mid 自己可能就是答案,所以只能把右边界收到 mid(而不是 mid−1)
return l循环结束时 l == r,这个位置满足「它是第一个 ≥ x 的」,直接返回

两种写法对照表(这张表建议背下来)

写法一:闭区间 + ans写法二:半开区间 [l, r)
维护的区间[l, r](闭)[l, r)(左闭右开)
初值l = 0, r = n - 1l = 0, r = n
循环条件while(l <= r)while(l < r)
命中时ans = mid; r = mid - 1;r = mid;
未命中时l = mid + 1;l = mid + 1;
结果在哪ans(没找到是 −1)l(没找到是 n)
要不要额外变量要 ans不要
🚫 千万不要把两种写法混着写

最常见的错误:用了 while(l <= r),却在命中时写 r = mid。看下面这个例子:

l = 3, r = 4          // 区间里有两个元素
mid = (3 + 4) / 2 = 3
若 a[mid] >= x 成立,执行 r = mid  →  r 还是 3
// l = 3, r = 3,和上一轮相比…… 一模一样!下一轮还是这些值 →
死循环!

// 正确写法(闭区间):r = mid - 1  →  r = 2,此时 l = 3 > r = 2,循环结束 ✅

★ 铁律:mid 的取整方向

🎯 死循环的根源就一句话

当 r = mid 时,mid 必须用 (l + r) / 2(向下取整);
当 l = mid 时,mid 必须用 (l + r + 1) / 2(向上取整)。

写反了,就是死循环。

为什么?因为 C++ 的整数除法是向下取整,而「取整方向」决定了 mid 会不会等于边界:

mid 的取法当 l < r 时,mid 一定满足所以
(l + r) / 2 向下取整l ≤ mid < r用它更新 r = mid 时,r 会变小 → 安全 ✅
用它更新 l = mid 时,l 可能不变(当 mid == l)→ 死循环 ❌
(l + r + 1) / 2 向上取整l < mid ≤ r用它更新 l = mid 时,l 会变大 → 安全 ✅
用它更新 r = mid 时,r 可能不变(当 mid == r)→ 死循环 ❌

再看那个死循环例子:l = 3, r = 4,mid = (3+4)/2 = 3,mid == l!所以 l = mid 之后 l 还是 3,什么都没变。

换成向上取整:mid = (3+4+1)/2 = 4,l = 4 —— l 从 3 变成 4,区间缩掉了 ✅

💡 记忆口诀

「保住谁,就往另一边取整。」

写 r = mid 是想「保住 mid 作为可能的右边界」,那么 mid 就得往下取整,保证它 < r,r 才会动。
写 l = mid 是想「保住 mid 作为可能的左边界」,那么 mid 就得往上取整,保证它 > l,l 才会动。

更省心的办法:一律用 mid ± 1 收缩(写法一),就永远不用纠结取整方向。所以新手请先写熟写法一。

那什么时候会用到「l = mid」的写法?

当你要求「最后一个 ≤ x 的位置」时,很自然的想法是:命中之后往右走,所以写 l = mid。这时 mid 就必须向上取整:

// 找最后一个 <= x 的位置(用 l = mid 的收缩方式)
int l = 0, r = n - 1;
while(l < r){
    int mid = (l + r + 1) / 2;      // ★ 向上取整,否则 l = 3, r = 4 时会死循环
    if(a[mid] <= x) l = mid;        //   mid 可能是答案,保住它
    else            r = mid - 1;    //   mid 一定不是答案,扔掉
}
// 结束时 l == r,但还要确认 a[l] <= x 是否真的成立(可能整个数组都 > x)
if(a[l] > x) /* 没找到 */;
⚠️ 顺便提醒:这种写法最后要额外验一次

写法二(半开区间)返回的 l 也可能落在 n 或者一个不满足条件的位置上——因为它们回答的是「第一个 ≥ x 的位置」,而这个位置上的数未必等于 x。

所以对 P2249 这种「找不到要输出 −1」的题,答案拿到手之后一定要再检查一遍 a[ans] == x。这是本题最常见的 WA 原因。

顺手说一个细节:mid 为什么要写 l + (r - l) / 2

当 l 和 r 都很大时(比如都在 10⁹ 附近),l + r 会超过 int 的上限(约 2.1×10⁹),变成负数,然后 mid 就成了个乱七八糟的值,程序直接跑飞。

l + (r - l) / 2 先做减法再做加法,结果和 (l + r) / 2 一样,但中间过程不会超过 r,所以永不溢出。本题下标最多 10⁶,不会溢出,但这是要养成的习惯。

4实数二分:别用 eps 当循环条件

和整数二分的区别

整数二分的区间是「下标」,一格一格地走,可以用 mid ± 1。实数二分的区间是「一段长度」,里面有无穷多个点,不能 ±1,只能一直对半砍。

double l = -100, r = 100;
for(int i = 0; i < 100; i++){        // ★ 固定循环 100 次
    double mid = (l + r) / 2;
    if(满足条件(mid)) r = mid;       // 答案在左半边
    else              l = mid;       // 答案在右半边
}
printf("%.2f\n", l);                 // 此时 l 和 r 已经贴得极近
🎯 为什么固定次数比 while(r - l > eps) 更稳

区间长度每轮减半。从 200 开始,砍 100 次之后长度是 200 ÷ 2¹⁰⁰ ≈ 1.6×10⁻²⁸,远远小于 double 能表示的最小间隔(double 大约只有 15~16 位有效十进制数字,实际上砍 60 次左右就到精度极限了)。

所以 100 次一定够,而且一定会在第 100 次准时停下来。

用 while(r - l > eps) 的风险:当 r 和 l 已经贴近到浮点数无法区分时,mid = (l + r) / 2 会算出等于 l 或等于 r 的值,区间长度再也变不小 → 死循环。这种 bug 还很难查,因为它只在精度耗尽时才出现。

P1024 的思路:先扫描,再二分

题目给了两个关键条件:根都在 [−100, 100] 里,而且根与根之差 ≥ 1。

第二条特别有用:任意一个长度为 1 的区间里,最多只有一个根(要是有两个,它俩的距离就 < 1 了)。

题目还给了判根定理:若 f(x₁) × f(x₂) < 0,则 (x₁, x₂) 里必有根。

  1. 扫描:让 i 从 −100 走到 99,检查每个小区间 [i, i+1]。因为根间距 ≥ 1,每个小区间最多一个根,好处理。
  2. 两种命中情况:
    • 左端点就是根:f(i) == 0 → 直接输出 i;
    • 两端异号:f(i) × f(i+1) < 0 → 在 (i, i+1) 里做 100 次实数二分。
  3. 别忘了右端点:扫描只覆盖到 [−100, 100] 的左端点,x = 100 要单独查一次。
  4. 去重:如果根恰好落在整数点上,相邻两个区间都可能「看到」它,需要防止同一个根被输出两次。因为根之间至少差 1,所以「新根和上一个已输出的根相差小于 0.5 就算同一个」这个判据是安全的。
💡 浮点判零为什么要用「容差」而不是 == 0

浮点运算有误差,f(i) 算出来可能是 1e-13 而不是精确的 0。

但容差也不能瞎定:如果系数很大,f(i) 的正常值可能有 10¹² 量级,用固定的 1e-6 反而会把「不是根」的点误判成根。

所以本文代码里用的是相对容差:先扫一遍求出 f 在整数点上的最大绝对值 maxf,再令 tol = maxf × 1e-9 + 1e-9。这样不管系数多大都能正确判断。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行过,「实测结果」里写的是我真实跑出来的输出。

P2249 【深基13.例1】查找 普及- · 二分模板题

题意:给 n 个单调不减的非负整数,m 次询问,每次给一个 q,输出 q 在序列中第一次出现的位置(从 1 开始编号),没出现就输出 −1。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 10⁶,0 ≤ aᵢ, q ≤ 10⁹,1 ≤ m ≤ 10⁵。

题面明确说「本题输入输出量较大,请使用较快的 IO 方式」→ 用 scanf/printf,或者开头加 ios::sync_with_stdio(false); cin.tie(0);

两个坑:①输出的是 1 开始的编号;②找不到要输出 −1,而 lower_bound 不会告诉你「找没找到」。

思路

数组已经单调不减,直接就是手写二分(写法一:闭区间 + ans)的模板题。

要的是「第一个 ≥ q 的位置」,所以命中之后不能停,要记下答案再往左找。

复杂度:排序都不用做(输入已经有序),每次询问 O(log n) → 总 O(m log n) ≈ 10⁵ × 20 = 2×10⁶。

样例数组的定位过程:

1
1
2
3
3
3
4
3
5
5
6
7
7
9

(上面用的是 1 开始的编号)查 3 → 第一个 3 在第 2 位;查 6 → 第一个 ≥ 6 的数在第 6 位,但它是 7 不是 6 → 输出 −1。

样例输入
11 3
1 3 3 3 5 7 9 11 13 15 15
1 3 6
样例输出
1 2 -1

完整代码

#include <cstdio>
using namespace std;

int a[1000005];
int n, m;

int main(){
    scanf("%d %d", &n, &m);
    for(int i = 0; i < n; i++) scanf("%d", &a[i]);

    for(int k = 0; k < m; k++){
        int q;
        scanf("%d", &q);
        // ★ 手写 lower_bound:找第一个 >= q 的位置
        int l = 0, r = n - 1, ans = -1;
        while(l <= r){
            int mid = l + (r - l) / 2;      // 防溢出写法
            if(a[mid] >= q){
                ans = mid;                  // 先记下来,再往左边继续找
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if(k) printf(" ");
        // ans 是第一个 >= q 的位置(没有就是 -1),还要确认它真的等于 q
        if(ans == -1 || a[ans] != q) printf("-1");
        else printf("%d", ans + 1);         // 题目要求输出 1 开始的编号
    }
    printf("\n");
    return 0;
}
✅ 实测结果

官方样例 → 输出 1 2 -1 完全一致

大数据:n = 10⁶(数组是 0,1,2,…,999999),m = 10⁵ 次询问 → 4 次取最小用时 485 ms(时限 1 s)
这个成绩已经包含了 Windows 下的进程启动和 10⁶ + 10⁵ 个整数的读写开销;洛谷是 Linux 环境,同代码通常更快。

边界 1:1 2 / 5 / 5 4(只有一个元素)→ 1 -1 正确

边界 2:5 4 / 0 0 0 1000000000 1000000000 / 0 1000000000 7 1000000001 → 1 4 -1 -1 正确
查 0 → 第 1 个;查 10⁹ → 第 4 个;查 7 → 第一个 ≥ 7 的是 10⁹ 但不等于 7 → −1;查 10⁹+1 → 全都 < 它 → −1。

易错点

  • 命中后直接 return:那样找到的是「某一个」q,不是「第一个」。必须 ans = mid; r = mid - 1; 继续往左。
  • 不验证 a[ans] == q:q = 6 时二分返回的是 7 的位置,不验证就会输出错误的位置而不是 −1。
  • 忘记 +1:题目编号从 1 开始,ans 是数组下标,要输出 ans + 1。
  • 用 cin 不开同步:10⁶ 个数 + 10⁵ 次询问,cin 不带 ios::sync_with_stdio(false) 会很慢。
  • 输出格式:m 个答案在同一行用空格隔开。用「第一个不加空格、后面都加」的写法(if(k) printf(" ");)最省事,也不用担心行尾多一个空格。
  • 数组下标从 0 开始,所以 a[1000005] 够用(n 最大 10⁶,多开 5 个保险)。
P1102 A-B 数对 普及- · 排序 + 二分计数

题意:给一串正整数和一个正整数 C,数出有多少个数对满足 A − B = C。不同位置的相同数字算不同的数对。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 2×10⁵,0 ≤ aᵢ < 2³⁰,1 ≤ C < 2³⁰。

答案要用 long long!最坏情况是 N 个数全都相等且 C = 0,此时数对数是 N² ≈ 4×10¹⁰,远超 int 的 21 亿。
注意 C ≥ 1,所以 C = 0 不会出现;但即便 C ≥ 1,答案上界也在 10¹⁰ 量级(一半的数等于 x,一半等于 x+C),所以仍然必须用 long long。

思路

等式 A − B = C 等价于 A = B + C,或者说「对每个 A,找有多少个 B 等于 A − C」。

于是:

  1. 排序(二分的必要前提)。
  2. 遍历每个 a[i] 当作 A,目标是 B = a[i] − C。
    代码里写的是 target = a[i] + C,因为「数组里有多少个 a[j] = a[i] + C」和「有多少个数对差为 C」是一一对应的,两种写法都对,只是把谁当 A 的区别。
  3. 用 upper_bound − lower_bound 数出 target 出现了几次,累加进答案。

复杂度:O(N log N)。

样例输入
4 1
1 1 2 3
样例输出
3

C = 1,排序后还是 1 1 2 3。
A = 1(第一个):B = A + 1 = 2,数组里有 1 个 → +1
A = 1(第二个):同上 → +1
A = 2:B = 3,数组里有 1 个 → +1
A = 3:B = 4,没有 → +0
共 3。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int a[200005];

int main(){
    ios::sync_with_stdio(false);            // 关掉 cin/cout 与 C 输入输出的同步,提速
    cin.tie(0);

    int n;
    long long c;
    cin >> n >> c;
    for(int i = 0; i < n; i++) cin >> a[i];
    sort(a, a + n);                         // ★ 二分的前提:先排序

    long long ans = 0;                      // ★ 答案要用 long long
    for(int i = 0; i < n; i++){
        long long target = (long long)a[i] + c;     // 需要多少个 a[j] == a[i] + c
        // 数 target 出现了几次:upper_bound - lower_bound
        int lo = (int)(lower_bound(a, a + n, target) - a);
        int hi = (int)(upper_bound(a, a + n, target) - a);
        ans += hi - lo;
    }
    cout << ans << endl;
    return 0;
}
💡 为什么 hi - lo 就是「等于 target 的个数」

lo = 第一个 ≥ target 的位置,hi = 第一个 > target 的位置。

排好序之后等于 target 的那些数一定挤在 [lo, hi) 这段连续区间里,长度正好是 hi - lo。

如果 target 根本不在数组里,lo 和 hi 会指向同一个位置,相减是 0,不用额外判断。

💡 这里为什么用 cin/cout 而不是 scanf/printf

因为答案和 C 都是 long long。printf 输出 long long 要写 %lld,而 Windows 上部分老编译器对这个格式符支持不完善(编译会警告,个别环境运行结果还会不对);cout 自己按类型选格式,Windows 和洛谷都没问题。

读入量最多 2×10⁵ 个数,加上 ios::sync_with_stdio(false); cin.tie(0); 之后速度完全够用。

✅ 实测结果

官方样例 → 输出 3 完全一致

手工验证:5 2 / 1 3 3 5 5 → 输出 6 正确
数一遍:3−1 有 2 对(两个 3 各配一次),5−3 有 4 对(两个 5 × 两个 3),共 6 对。

大数据(合法最大规模):N = 200000,10 万个 1 + 10 万个 2,C = 1 → 输出 10000000000(= 10⁵ × 10⁵ = 10¹⁰),用时 64 ms
注意题面规定 C ≥ 1,所以这个才是真正的最坏情况(C = 0 的数据本身不合法)。这组数据专门用来验证 long long:用 int 存答案会溢出。

易错点

  • 答案用 int:会溢出 → WA。这是本题的头号错误。
  • 忘记排序:二分在乱序数组上毫无意义。
  • 把重复元素只算一次:题目明确「不同位置的数字一样的数对算不同的数对」,所以两个 1 要分别算,用 ub − lb 正好自动处理。
  • target 用 int 相加:aᵢ 可以接近 2³⁰,a[i] + C 会超过 int 上限(2³¹−1)吗?本题不会(2³⁰−1 + 2³⁰−1 = 2³¹−2 < 2³¹−1),但写成 (long long)a[i] + c 更稳,不用去算边界。
  • 把 lower_bound 传成 a + n - 1:范围写错会漏掉最后一个元素。标准的写法是 [a, a+n)。
  • C 的读入格式写错:C 可以到 2³⁰ 量级,用 scanf("%d", &c) 读成 int 在本题还能勉强撑住,但换成更大的数据就会读错。答案和 C 一律用 long long,读入也用 cin >> c 或 %lld。
  • 用 cin 却不开同步:读 2×10⁵ 个数会慢一截,记得 ios::sync_with_stdio(false); cin.tie(0);。
P1678 烦恼的高考志愿 普及- · 二分找最接近值

题意:m 所学校的分数线 aᵢ,n 个学生的估分 bᵢ。给每个学生配一所学校,使「分数线与估分之差」最小(可以高也可以低),这个最小值叫这名学生的不满意度。求所有学生不满意度的总和。

⚠️ 数据范围 / 关键约束

1 ≤ n, m ≤ 10⁵,0 ≤ aᵢ, bᵢ ≤ 10⁶。

答案要用 long long:每个学生的不满意度最多 10⁶,n 最多 10⁵,总和最大 10¹¹,超过 int。

朴素做法是两层循环 O(nm) = 10¹⁰ → 必 TLE。必须二分。

思路

先给分数线排序。对每个学生的估分 b:

  1. 用 lower_bound 找到第一个 ≥ b 的分数线,位置记为 p。
  2. 离 b 最近的分数线,一定是 a[p] 和 a[p−1] 中的一个(见下面的解释),把两者的差值取小的那个即可。
  3. 注意边界:p == m 说明所有分数线都 < b,右边没有候选;p == 0 说明所有分数线都 ≥ b,左边没有候选。
🎯 为什么只需要看两个候选

把分数线按大小排成一排,b 夹在中间。比 b 小的那些分数线里,最接近 b 的一定是最大的那个;比 b 大的那些里,最接近 b 的一定是最小的那个。

而 lower_bound 给出的 a[p] 正好是「大于等于 b 的最小值」,a[p-1] 正好是「小于 b 的最大值」。所以最优解必在这两个里面。

样例输入
4 3
513 598 567 689
500 600 550
样例输出
32

分数线排序后:513、567、598、689。
估分 500 → 最近的 513,差 13;
估分 600 → 最近的 598,差 2;
估分 550 → 513 差 37,567 差 17,取 17。
合计 13 + 2 + 17 = 32。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int a[100005], b[100005];

int main(){
    ios::sync_with_stdio(false);        // 关掉 cin/cout 与 C 输入输出的同步,提速
    cin.tie(0);

    int m, n;
    cin >> m >> n;
    for(int i = 0; i < m; i++) cin >> a[i];
    for(int i = 0; i < n; i++) cin >> b[i];
    sort(a, a + m);                         // 学校分数线排序

    long long ans = 0;                      // ★ 最多 1e5 × 1e6 = 1e11,必须 long long
    for(int i = 0; i < n; i++){
        // p = 第一个 >= b[i] 的分数线
        int p = (int)(lower_bound(a, a + m, b[i]) - a);
        int best = 1 << 30;                 // 先设一个大数
        if(p != m) best = min(best, a[p] - b[i]);       // 右边最近的
        if(p != 0) best = min(best, b[i] - a[p - 1]);   // 左边最近的
        ans += best;
    }
    cout << ans << endl;                    // 用 cout 输出 long long,不会有格式符的问题
    return 0;
}
💡 为什么这两题我用 cin/cout 而不是 scanf/printf

因为要输出 long long,而 printf 输出 long long 得写 %lld。在 Windows 的部分编译器(尤其是老版本 MinGW / Dev-C++)上,printf 的 %lld 支持并不完善:编译时会警告,个别环境里运行结果还会不对。

cout 会自己按类型选格式,Windows 和洛谷(Linux)上都没问题,所以输出 long long 时用它最省心。
记得加 ios::sync_with_stdio(false); cin.tie(0);,不然读 2×10⁵ 个数会慢一截。

顺便说一句:在洛谷(Linux g++)上 printf("%lld", x) 是完全正确的标准写法,你看到别人这么写也不用奇怪。

✅ 实测结果

官方样例 → 输出 32 完全一致

大数据:m = n = 10⁵,所有分数线都是 0,所有估分都是 10⁶ → 输出 100000000000(= 10⁵ × 10⁶ = 10¹¹),4 次取最小用时 76 ms
这组数据既验证了 long long(10¹¹ 会撑爆 int),也验证了「所有分数线都小于估分」时 p == m 的边界分支。

易错点

  • 答案用 int:10¹¹ 溢出 → WA。
  • 只看 a[p] 不看 a[p-1]:题目说「可高可低」,只往上看会得到偏大的不满意度。
  • 越界访问 a[p-1]:当 p == 0 时 a[-1] 是非法访问,必须判掉。
  • 忘记 p == m 的判断:此时 a[p] 指向数组外面。
  • 用两重循环暴力:O(nm) = 10¹⁰ → TLE。
  • 被「估分可能相等」绕晕:当 a[p] == b[i] 时差值是 0,代码自然就取到 0 了,不用特判。
P1024 [NOIP 2001 提高组] 一元三次方程求解 普及/提高- · 实数二分

题意:解方程 ax³ + bx² + cx + d = 0,系数是实数。约定方程有三个不同实根,根都在 [−100, 100] 内,且根与根的差 ≥ 1。从小到大输出三个根,精确到小数点后 2 位。

⚠️ 数据范围 / 关键约束

题面没有给出系数的数值范围,只给了根的性质:三个不同实根、都在 [−100, 100]、两两之差 ≥ 1。

题目还给了判根提示:若存在 x₁ < x₂ 使 f(x₁) × f(x₂) < 0,则 (x₁, x₂) 之间一定有根。

思路

「根与根之差 ≥ 1」意味着任意长度为 1 的区间里最多只有一个根。于是把 [−100, 100] 按 1 切成 200 个小区间,逐个检查:

  • 如果左端点 f(i) 就是 0 → i 本身是根,直接记下;
  • 否则看 f(i) 和 f(i+1) 是否异号 → 异号就在 (i, i+1) 里做 100 次实数二分;
  • 最后单独补查 x = 100。

因为每个小区间最多一个根,去重只要判断「新根和上一个根相差是否小于 0.5」就行。

浮点判零用相对容差:先求出 f 在整数点上的最大绝对值 maxf,再取 tol = maxf × 1e-9 + 1e-9。

样例输入
1 -5 -4 20
样例输出
-2.00 2.00 5.00

这个多项式正好能分解:x³ − 5x² − 4x + 20 = (x + 2)(x − 2)(x − 5),三个根都是整数。

完整代码

#include <cstdio>
#include <cmath>

double a, b, c, d;
double ans[3];
int cnt = 0;

double f(double x){
    return ((a * x + b) * x + c) * x + d;   // 秦九韶算法,少算几次乘法
}

// 记录一个根;根之间至少差 1,所以差距小于 0.5 的算同一个根
void add(double r){
    for(int i = 0; i < cnt; i++)
        if(fabs(ans[i] - r) < 0.5) return;
    if(cnt < 3) ans[cnt++] = r;
}

int main(){
    scanf("%lf %lf %lf %lf", &a, &b, &c, &d);

    // 用一个「相对」容差判断整数点是不是根,避免系数很大时误判
    double maxf = 0;
    for(int i = -100; i <= 100; i++){
        double v = fabs(f((double)i));
        if(v > maxf) maxf = v;
    }
    double tol = maxf * 1e-9 + 1e-9;

    for(int i = -100; i < 100 && cnt < 3; i++){
        double fl = f((double)i), fr = f((double)(i + 1));
        if(fabs(fl) < tol){                 // 情况 1:整数点本身就是根
            add((double)i);
            continue;
        }
        if(fl * fr < 0){                    // 情况 2:两端异号,区间里必有一个根
            double l = i, r = i + 1;
            for(int k = 0; k < 100; k++){   // 固定二分 100 次,精度足够
                double mid = (l + r) / 2;
                if(f(l) * f(mid) <= 0) r = mid;
                else l = mid;
            }
            add((l + r) / 2);
        }
    }
    if(cnt < 3 && fabs(f(100.0)) < tol) add(100.0);      // 别忘了右端点

    for(int i = 0; i < cnt; i++){
        if(i) printf(" ");
        printf("%.2f", ans[i]);
    }
    printf("\n");
    return 0;
}
✅ 实测结果

官方样例 1 -5 -4 20 → 输出 -2.00 2.00 5.00 完全一致

另外我跑了 5 组补充数据,全部正确:

输入 (a b c d)实际输出说明
1 -6 11 -61.00 2.00 3.00三个整数根,全靠「整数点判根」这一支命中
1 -6.5 12.5 -71.00 2.00 3.50含小数根,靠区间二分命中,3.50 精确到两位
1 0 -1 0-1.00 0.00 1.00有一根正好是 0,验证 i = 0 这个位置
-1 5 4 -20-2.00 2.00 5.00三次项系数为负,验证符号处理
1 0 -100 0-10.00 0.00 10.00根靠近区间内部,验证根的顺序是从小到大

顺带一提:1 -6 11 -6 这组里三个根都落在整数点上,如果只写「区间异号二分」而不处理「整数点就是根」的情况,就会一个根都找不到。

易错点

  • 只判区间异号,不管整数点是根:根恰好在整数点时,f(i) = 0,两端乘积是 0 而不是负数,fl * fr < 0 不成立,根就被漏掉了。
  • 忘记 x = 100:扫描区间是 [−100, 100],循环里只覆盖到 i = 99 的右端 100,最后一个点要单独判。
  • 用 while(r - l > eps):精度耗尽时 mid 可能等于 l 或 r,区间不再缩小 → 死循环。用固定 100 次更稳。
  • 忘记去重:根落在整数点上时,可能被相邻两个区间各认领一次,同一个根输出两遍,第三个根就没位置了。
  • 用 fabs(f(i)) == 0 精确判零:浮点误差下几乎不会恰好等于 0,要用容差。
  • 容差写死成 1e-6:系数很大时 f 的正常值可能有 10¹² 量级,固定的小容差会把非根点误判成根。所以用相对容差。
  • 输出格式:同一行、空格分隔、%.2f。用「第一个不加空格」的写法避免行尾多空格。

✗今日常见错误汇总

错误写法后果正确写法
在乱序数组上二分结果随机,毫无意义先 sort(a, a+n)
lower_bound 之后直接当成「找到了」P2249 输出错误位置而不是 −1if(pos < n && a[pos] == x) 再验一次
while(l <= r) 配 r = mid死循环(mid 可能等于 r,区间不变)闭区间用 r = mid - 1
while(l < r) 配 l = mid(mid 向下取整)死循环(mid 可能等于 l,区间不变)用 l = mid 时必须 mid = (l+r+1)/2 向上取整
半开区间却写 r = n - 1答案可能是 n(一个都不满足),漏掉这种情况while(l < r) 时初值用 r = n
命中后立刻 return mid找到的是「某一个」而不是「第一个」ans = mid; r = mid - 1; 继续往左
P2249 输出 ans 而不是 ans+1编号整体差 1 → WA题目编号从 1 开始,要 ans + 1
P1102 / P1678 答案用 int4×10¹⁰ 或 10¹¹ 溢出 → WA用 long long,输出 %lld
P1678 只看 a[p]忽略左边更近的分数线,答案偏大同时比较 a[p] 和 a[p-1]
P1678 在 p == 0 时访问 a[p-1]非法访问(RE 或读到垃圾值)加 if(p != 0) 判断
P1024 用 while(r - l > 1e-8)精度耗尽时死循环固定循环 100 次
P1024 不处理「整数点就是根」三个根全在整数点上时,一个都找不到先判 fabs(f(i)) < tol
mid = (l + r) / 2(l, r 很大)l + r 溢出 int → mid 变负 → REmid = l + (r - l) / 2

✓自测:你真的会了吗

第 1 题:lower_bound 和 upper_bound 的语义分别是什么?差在哪里?

答:lower_bound(a, a+n, x) 返回第一个 ≥ x 的位置;upper_bound(a, a+n, x) 返回第一个 > x 的位置。

两者只差一个等号——判定的条件分别是 a[mid] >= x 和 a[mid] > x。

它们的差 upper_bound - lower_bound 就是 x 在数组里出现的次数。

第 2 题:数组 1 3 3 3 5 7 9(下标从 0 开始),lower_bound(3) 和 upper_bound(3) 分别返回什么?

答:lower_bound = 1(第一个 3 的下标),upper_bound = 4(5 的下标,也就是第一个 > 3 的位置)。

4 − 1 = 3,正好是 3 的个数。

第 3 题:while(l <= r) 和 while(l < r) 分别配什么样的区间?

答:

  • while(l <= r) 配闭区间 [l, r]:两端都是可能藏着答案的合法下标,初值 l = 0, r = n - 1;l > r 时区间为空。
  • while(l < r) 配半开区间 [l, r):r 是「答案不可能到达的位置」,初值 l = 0, r = n;l == r 时区间为空。

关键是两种写法里的收缩方式必须配套:闭区间命中后写 r = mid - 1(因为已经判定过 mid 了),半开区间命中后写 r = mid(因为 mid 自己可能是答案)。混用就会死循环。

第 4 题:为什么 r = mid 时 mid 必须向下取整?举个会死循环的例子。

答:向下取整时,只要 l < r,就一定有 mid < r。于是 r = mid 会让 r 严格变小,区间必定缩小,不会卡住。

如果写 r = mid 却用向上取整:mid = (l + r + 1) / 2,当区间只剩两个元素(比如 l = 3, r = 4)时 mid = 4 = r,r = mid 之后 r 还是 4,l 也没变 → 下一轮完全相同 → 死循环。

对称地,l = mid 时必须向上取整,否则 l = 3、r = 4 时 mid = 3 = l,l = mid 之后 l 不变 → 同样死循环。

第 5 题:为什么实数二分推荐「固定循环 100 次」而不是 while(r - l > eps)?

答:因为每次区间长度减半,从 200 开始砍 100 次,长度是 200 ÷ 2¹⁰⁰ ≈ 1.6×10⁻²⁸,早就远超 double 的精度极限了,所以 100 次一定够,而且一定会停。

用 eps 判断的风险是:当 r 和 l 贴近到浮点数无法再区分时,mid = (l + r) / 2 会算出等于 l 或等于 r 的值,区间长度永远不再变小 → 死循环。

第 6 题(手写代码):写出「找第一个 ≥ x 的位置」的闭区间版本。
int l = 0, r = n - 1, ans = -1;
while(l <= r){
    int mid = l + (r - l) / 2;
    if(a[mid] >= x){ ans = mid; r = mid - 1; }
    else            l = mid + 1;
}
if(ans != -1 && a[ans] != x) ans = -1;   // 题目若要求「必须等于 x」再补这一句

★ 三个要点:while(l <= r)、用 ans 记录、收缩用 mid ± 1。

▤速查卡(截图存手机)

【STL 三件套】
  #include <algorithm>
  int lo = lower_bound(a, a+n, x) - a;   // 第一个 >= x 的位置
  int hi = upper_bound(a, a+n, x) - a;   // 第一个 >  x 的位置
  int cnt = hi - lo;                     // x 出现的次数
  bool exist = (lo < n && a[lo] == x);   // ★ 一定要再验一次!

【手写 lower_bound(闭区间 + ans,推荐新手)】
  int l = 0, r = n - 1, ans = -1;
  while(l <= r){
      int mid = l + (r - l) / 2;
      if(a[mid] >= x){ ans = mid; r = mid - 1; }
      else            l = mid + 1;
  }
  // ans == -1 表示没找到

【手写 lower_bound(半开区间,返回 l)】
  int l = 0, r = n;                    // ★ r = n
  while(l < r){
      int mid = (l + r) / 2;
      if(a[mid] >= x) r = mid;         // ★ 不减 1
      else            l = mid + 1;
  }
  // l == n 表示没找到

【找最后一个 <= x(用 l = mid,必须向上取整)】
  int l = 0, r = n - 1;
  while(l < r){
      int mid = (l + r + 1) / 2;       // ★★ 向上取整,否则死循环
      if(a[mid] <= x) l = mid;
      else            r = mid - 1;
  }

【★ 铁律:mid 的取整方向】
  r = mid   →  mid 用 (l + r) / 2     向下取整
  l = mid   →  mid 用 (l + r + 1) / 2 向上取整
  写反了 = 死循环。省心办法:一律用 mid ± 1 收缩。

【实数二分】
  double l = -100, r = 100;
  for(int i = 0; i < 100; i++){        // 固定次数,别用 eps
      double mid = (l + r) / 2;
      if(条件(mid)) r = mid; else l = mid;
  }
  printf("%.2f\n", l);

【复杂度】
  排序 O(n log n) + 每次查询 O(log n)
  n = 10^6 → 约 20 次;n = 10^9 → 约 30 次

→明天预告 · Day 23

二分答案:把「求解」变成「判定」

今天你学的是「在一个有序数组里找某个值」。明天要把它升级成一个更强大的思维工具:

当题目要求「最大值最小」或者「最小值最大」时,你不需要直接算出那个答案,只需要会判断「某个值行不行」,然后二分这个值就行了。

明天会学到:

明天四道题:P1873 砍树、P2440 木材加工、P1182 数列分段、P3853 路标设置。今天先把二分的边界练到不出错——因为二分答案的代码量,一半都在二分的框架上。