阶段 1 · 基础技巧 · 第 8 章

二分查找:把边界一次性钉死

二分的思想三句话说完,可它是最容易写错的算法 —— 这一章只教一个模板,然后把它焊死。

例题:有序数组里查 lower / upper 建议用时:100 分钟
这一章的目标只有一个

二分的思想你早就会了 —— 猜数字游戏,猜大了往小猜,猜小了往大猜。三句话说完。

但二分是竞赛里最容易写错的算法,没有之一。错法五花八门: 死循环、少算一个、多算一个、越界。而且这些错误常常能过样例, 在大数据上才发作。

所以这一章不追求「讲清楚思想」,追求的是: 让你从此以后,写二分不再靠试。

办法是只学一个模板,然后把它焊死在肌肉记忆里。

1 一句话问题

给一个从小到大排好序的数组(可以有重复),q 次询问,每次给一个 x,回答三件事:

L = 第一个 >= x 的位置        (都比 x 小就输出 n+1)
U = 第一个 >  x 的位置        (都不比 x 大就输出 n+1)
C = x 出现了几次
输入 8 3
     1 3 3 4 4 4 7 9
     4                        L=4(a[4]=4 是第一个 ≥4 的)U=7(a[7]=7)C=3
     5                        L=7 U=7 C=0(5 不存在)
     100                      L=9 U=9 C=0(比谁都大,落到 n+1)
输出 4 7 3
     7 7 0
     9 9 0
✓ 为什么一口气问这三个

因为它们是二分查找的三种最常见需求,而且有个漂亮的关系:

C = U - L。

「x 出现了几次」根本不用单独写代码 —— 两个二分一减就出来了。 这三个量在 C++ 标准库里就叫 lower_boundupper_bound, 名字值得记住,第 7 步会用到。

2 先用纸笔手算一遍

1 3 3 4 4 4 7 9x = 4

位置:  1  2  3  4  5  6  7  8   (9)
值:    1  3  3  4  4  4  7  9    ∞
                ↑           ↑
                L=4         U=7

注意最右边那个虚构的位置 9(也就是 n+1)。它不存在于数组里, 但必须把它算成候选:当 x = 100 时,答案就落在那儿。

★ 先把这个想清楚,代码就不会错

我们要找的不是「等于 x 的位置」,而是**「分界线」**:

   小于 x 的一段          |    >= x 的一段
   1  3  3               |    4  4  4  7  9  ∞

                     这条线的位置就是 L

数组被 x 切成两半:左边全是「不满足」,右边全是「满足」。 二分要找的就是这条分界线。

这个视角非常重要,因为它把「找一个数」变成了「找一个分界点」—— 而分界点一定存在(哪怕在最左边或最右边),不需要特判「找不到」。 第 9 章的二分答案,用的就是这个视角。

3 暴力:从头一个个看

brute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果

O(n) 一次询问。它慢,但它是那三个定义最诚实的表述 —— 待会儿拿它当标准答案。

4 实测:它有多慢

同题对比:线性扫描 vs 二分
询问次数也是 n,而且 x 特意取在数组后半段(线性扫描是从左往右的,x 越靠后它扫得越久)。跑完改成 100000 再来一次。
线性扫描
二分

本机实测:

n = q线性扫描 O(n·q)二分 O(q log n)
20 0000.20 秒0.01 秒
50 0001.32 秒0.02 秒
100 0005.21 秒0.04 秒
✓ log n 到底有多小
nlog₂n(大约要几轮)
1 00010
1 000 00020
1 000 000 00030

数据规模从一千涨到十亿(一百万倍),二分只从 10 轮变成 30 轮。

这就是为什么「能二分就二分」—— 它几乎是免费的。

5 ★ 关键的一步:只学一个模板

★ 关键的一步
int l = 1, r = n + 1;              // 候选范围 [l, r],多留 n+1 那一格表示「找不到」
while (l < r) {
    int mid = l + (r - l) / 2;
    if (check(mid)) r = mid;       // mid 满足 → 答案在 [l, mid],mid 自己也算候选
    else l = mid + 1;              // mid 不满足 → 答案在 [mid+1, r],mid 被淘汰
}
// 出来时 l == r,就是答案

这一章只有这一个模板。所有变化都只是换 check

找第一个 >= x   →   check(mid) 是  a[mid] >= x
找第一个 >  x   →   check(mid) 是  a[mid] >  x

写的时候不要背口诀,只问自己一句话:

「mid 满足条件的时候,答案还可能在 mid 右边吗?」

不可能 → r = mid(mid 得留着,它可能就是答案) 可能 → l = mid + 1(mid 没用了,扔掉)

三个细节,每一个都能让你 WA:

⚠ 1. 循环条件是 l < r,不是 l <= r

l == r 时区间里只剩一个数,它已经是答案了。再进循环就可能原地打转。

l <= r 的写法也有正确版本,但它要配 r = mid - 1 和一个额外的 ans 变量。 两套模板混着记 = 一定写错。这一章只认上面那一个。

⚠ 2. 两个分支不对称:一个是 mid,一个是 mid + 1

因为 mid下取整的。当区间只剩两个数(比如 l=3, r=4)时:

mid = 3 + (4 - 3) / 2 = 3        ← mid 等于 l

如果这时走的分支是 l = mid,那 l 还是 3,r 还是 4,区间一点都没缩小 —— 下一轮 mid 还是 3,永远循环下去。

下一步就让你亲眼看它死给你看。

⚠ 3. mid 要写 l + (r - l) / 2,别写 (l + r) / 2

l + r 在两者都接近 int 上限时会溢出成负数,然后数组访问越界。

本章的数据不会触发,但这是个免费的好习惯 —— 打字就多两个字符。

6 亲眼看一次死循环

deadloop.cpp⚠ 故意写错的
点运行。它会打印几轮之后卡住,最后被判「超时」—— 那不是机器慢,是这段代码永远跑不完。
输出
点「运行 ▶」看结果

输出会是这样:

第 1 轮:l = 1, r = 6, mid = 3
第 2 轮:l = 3, r = 6, mid = 4
第 3 轮:l = 3, r = 4, mid = 3
第 4 轮:l = 3, r = 4, mid = 3      ← 和上一轮一模一样
第 5 轮:l = 3, r = 4, mid = 3      ← 还是一模一样
...
★ 从此以后,写完二分做这一件事

拿「区间只剩两个数」代进去,手动走一轮。

只要有任何一个分支让区间没缩小,就是死循环。

这个检查花不了十秒钟,但它能挡住二分 90% 的翻车。 比赛的时候尤其值得做 —— 死循环意味着这道题 0 分, 而且你在考场上很难意识到「它不是慢,是根本停不下来」。

另一种写法:上取整配 l = mid

如果把 mid 改成上取整

int mid = l + (r - l + 1) / 2;

那么配 l = mid / r = mid - 1 就是对的(这是「找最后一个满足条件的位置」常用的写法)。

关键是:mid 的取整方向和分支的写法必须配套,不能混搭。 记不住就只用第 5 步那一个模板 —— 它能解决这一章和第 9 章的全部问题。

7 正解 + 标准库的写法

fast.cpp正解
输入(stdin)
输出
点「运行 ▶」看结果

两个函数长得几乎一模一样,唯一的区别是 >=>。这就是模板的价值。

C++ 标准库早就把它们写好了:

stl.cppSTL 版
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 有 STL 了,为什么还要手写

两个理由:

  1. 第 9 章的二分答案根本没有数组给你 lower_bound 那里二分的是「答案本身」,check 是一个自己写的函数。手写模板必须会。
  2. lower_bound 用在 set/map 上会退化成 O(n) —— 它们不是连续存储的。那两个容器要用自己的成员函数 s.lower_bound(x)

比赛里能用 STL 就用(少写十行少十个错),但模板要能默写。

8 单步看区间怎么减半

二分:每一轮砍掉一半候选
第 1 / 6 步
1
3
3
4
4
4
7
9
9
12
15
20
l
r
1
2
3
4
5
6
7
8
9
10
11
12
13
还剩多少个候选
13
候选区间
[1, 13]
最右边那格虚线 ∞ 是数组外面的 n+1 —— 它是「找不到」的落脚点, 所以模板里 r 的初值必须是 n+1,不是 n。
要找「第一个 >= 4 的位置」。候选范围一开始是 [1, 13] —— 多留出 13 这一格,专门表示「整个数组都不满足」。

盯住两件事:

  • 蓝色的候选区间每一轮恰好少一半,一次都没有例外。
  • 最右边那个虚线的 就是 n+1。把 x 改成 100 播一遍 —— 它最后收敛到那一格。这就是模板里 r 初值必须是 n+1 而不是 n 的原因。

然后切到「第一个 > x」再看一遍:整个过程只有一个符号变了>= 变成 >。 数组里有三个 4,两种模式的落点正好把它们夹在中间。

trace.cpp过程演示
文字版的同一件事,还会告诉你实际轮数和 log2(n+1) 的理论上限。
输入(stdin)
输出
点「运行 ▶」看结果

9 ★ 对拍验证

★ 正确的用法

把「二分」那一栏换成你自己默写的,再点开始。

对拍器
生成器专攻边界:数组取值只有 0~8(重复元素满地都是,lower 和 upper 的区别才看得出来)、n 会取到 1、x 一半命中一半不命中,还会出现比所有数都小 / 都大的情况。

值得故意写错的,每一个都是真实高频错误:

  • r 的初值写成 n → 「答案是 n+1」那种情况永远算不出来
  • l = mid(少了 +1) → 死循环(对拍会报「超时」)
  • while (l <= r) → 死循环
  • lowerPos 里的 >= 写成 > → 变成了 upper,重复元素时才会暴露 —— 这就是为什么生成器一定要造重复元素
  • mid = (l + r) / 2 → 本题不会炸,但记住它有溢出风险
✓ 注意这个生成器的设计

如果数组里所有元素互不相同,那么 lowerupper 只差 1, 把 >= 写成 > 的错误几乎抓不住

所以生成器把取值压到只有 0~8 —— n=12 的数组里必然有大量重复。 造数据的原则(第 1 章就说过):让「错误的直觉」在你的数据上必定失败。

10 二分的适用条件

★ 二分不要求「有序」,要求的是「单调」

课本上说「二分用于有序数组」,这句话太窄了。真正的条件是:

存在一个分界点,左边全部不满足,右边全部满足(或者反过来)。

不满足 不满足 不满足 | 满足 满足 满足 满足

                  分界点

有序数组 + 「>= x」这个条件,恰好满足上面这个形状。 但很多题里根本没有数组 —— 只要「答案越大越容易满足」,就能二分。

那就是下一章的全部内容。

11 自测

自测清单0 / 8
配套练习
  • 洛谷 P2249 查找 —— lower_bound 模板题。先手写一遍,再用 STL 写一遍,对比结果
  • 洛谷 P1102 A-B 数对 —— 上一章用双指针做过,现在用「排序 + 两次二分」再做一遍 —— 这题正好是 U-L 的用武之地
  • 洛谷 P1873 砍树 —— 二分答案。做不出来很正常,下一章就讲它 —— 但可以先自己试试
  • 洛谷 P1024 一元三次方程求解 —— NOIP2001。实数二分,注意精度和循环终止条件(不能用 l < r)
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 9 章二分答案,是二分真正的杀手锏,也是 CSP-J/S 的高频考点。

它的套路很反直觉:不会直接求答案?那就猜一个,再写个函数验证这个猜测行不行。 而「验证」通常比「求解」简单一百倍。

这一章的模板会原封不动地用上,只是 check 从「比大小」变成「一段贪心」。