二分的思想你早就会了 —— 猜数字游戏,猜大了往小猜,猜小了往大猜。三句话说完。
但二分是竞赛里最容易写错的算法,没有之一。错法五花八门: 死循环、少算一个、多算一个、越界。而且这些错误常常能过样例, 在大数据上才发作。
所以这一章不追求「讲清楚思想」,追求的是: 让你从此以后,写二分不再靠试。
办法是只学一个模板,然后把它焊死在肌肉记忆里。
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 5 100
输出
4 7 3 7 7 0 9 9 0
第一行是 n 和 q,第二行是数组,之后 q 行每行一个 x。输出一行对一个询问。
x = 4:a[4]是第一个 ≥ 4 的,所以L = 4;a[7] = 7是第一个 > 4 的,所以U = 7;4 出现了7 - 4 = 3次。x = 5:数组里没有 5,L = U = 7,出现0次。x = 100:比谁都大,L和U都落到n + 1 = 9。
因为它们是二分查找的三种最常见需求,而且有个漂亮的关系:
C = U - L。
「x 出现了几次」根本不用单独写代码 —— 两个二分一减就出来了。
这三个量在 C++ 标准库里就叫 lower_bound、upper_bound,
名字值得记住,第 7 步会用到。
2先用纸笔手算一遍
拿 1 3 3 4 4 4 7 9 找 x = 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暴力:从头一个个看
// 有序数组里查找 —— 暴力:从头一个个看过去//// 输入:第一行 n q// 第二行 n 个**从小到大**排好序的整数(可以有重复)// 接下来 q 行,每行一个 x// 输出:q 行,每行三个数 L U C// L = 第一个 >= x 的位置(都比 x 小就输出 n+1)// U = 第一个 > x 的位置(都不比 x 大就输出 n+1)// C = x 在数组里出现了几次//// 为什么要同时输出这三个?因为它们正好是二分查找的三种常见需求,// 而且 C = U - L 这个关系本身就很值得记住 ——// 「出现了几次」不用另写代码,两个二分一减就出来了。//// 这份暴力从头扫到尾,O(n) 一次询问,总共 O(nq)。// 完全正确,而且**它是那三个定义的最诚实的表述** —— 对拍时拿它当标准答案。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, q; if (!(cin >> n >> q)) return 0;
vector<long long> a(n + 1, 0); for (int i = 1; i <= n; i++) cin >> a[i];
for (int k = 0; k < q; k++) { long long x; cin >> x;
int L = n + 1, U = n + 1; for (int i = 1; i <= n; i++) if (a[i] >= x) { L = i; break; } // 第一个 >= x for (int i = 1; i <= n; i++) if (a[i] > x) { U = i; break; } // 第一个 > x
cout << L << " " << U << " " << (U - L) << "\n"; } return 0;}点「运行 ▶」看结果
O(n) 一次询问。它慢,但它是那三个定义最诚实的表述 —— 待会儿拿它当标准答案。
4实测:它有多慢
本机实测:
| n = q | 线性扫描 O(nq) | 二分 O(q log n) |
|---|---|---|
| 20 000 | 0.20 秒 | 0.01 秒 |
| 50 000 | 1.32 秒 | 0.02 秒 |
| 100 000 | 5.21 秒 | 0.04 秒 |
| n | log₂n(大约要几轮) |
|---|---|
| 1 000 | 10 |
| 1 000 000 | 20 |
| 1 000 000 000 | 30 |
数据规模从一千涨到十亿(一百万倍),二分只从 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:
l == r 时区间里只剩一个数,它已经是答案了。再进循环就可能原地打转。
(l <= r 的写法也有正确版本,但它要配 r = mid - 1 和一个额外的 ans 变量。
两套模板混着记 = 一定写错。这一章只认上面那一个。)
因为 mid 是下取整的。当区间只剩两个数(比如 l=3, r=4)时:
mid = 3 + (4 - 3) / 2 = 3 ← mid 等于 l如果这时走的分支是 l = mid,那 l 还是 3,r 还是 4,区间一点都没缩小 ——
下一轮 mid 还是 3,永远循环下去。
下一步就让你亲眼看它死给你看。
l + r 在两者都接近 int 上限时会溢出成负数,然后数组访问越界。
本章的数据不会触发,但这是个免费的好习惯 —— 打字就多两个字符。
6亲眼看一次死循环
// ⚠ 这是一份**故意写错**的二分 —— 它会死循环//// 点「运行 ▶」跑一下,你会看到「超时,被强制结束」。// 这不是机器慢,是这段代码永远跑不完。//// 错在哪:把 l = mid + 1 写成了 l = mid。//// 看着好像更保险(「万一 mid 就是答案呢」),实际上是致命的://// 当区间只剩两个数时,比如 l = 3, r = 4:// mid = 3 + (4 - 3) / 2 = 3 ← 下取整,mid 等于 l// 如果这时走 else 分支(l = mid),l 还是 3,r 还是 4,// **区间一点都没变**,下一轮算出来的 mid 还是 3……//// 于是它就在这里原地打转,直到被判超时。//// 记住这个判断方法:// **写完二分,拿「区间只剩两个数」这种最小情况代进去走一遍。**// 只要有一个分支让区间没缩小,就是死循环。//// 想验证「另一种写法」也一样:把 mid 改成上取整// int mid = l + (r - l + 1) / 2;// 配 l = mid 就是对的,配 r = mid 又会死循环。// 所以 **mid 的取整方向和分支的写法必须配套**,不能混搭。
#include <bits/stdc++.h>using namespace std;
int main() { // 一个写死的小例子,不用输入 vector<long long> a = {0, 1, 3, 3, 5, 7}; // a[1..5],1 基 int n = 5; long long x = 4; // 找第一个 >= 4 的位置(正确答案是 5)
cout << "在 1 3 3 5 7 里找第一个 >= 4 的位置(正确答案:5)\n"; cout << "如果这段输出停住了,就是死循环了 —— 等它超时被掐断。\n\n";
int l = 1, r = n + 1; long long step = 0; while (l < r) { int mid = l + (r - l) / 2; step++;
// 只打印前几轮 —— 后面全都一模一样,打出来只会刷屏 if (step <= 8) { cout << "第 " << step << " 轮:l = " << l << ", r = " << r << ", mid = " << mid << "\n"; } else if (step == 9) { cout << "……从这里开始,每一轮的 l、r、mid 都和上一轮完全相同。\n"; cout << "区间不再缩小,循环永远不会结束。等它超时吧。\n"; } cout.flush();
if (a[mid] >= x) r = mid; else l = mid; // ← 错!这里必须是 mid + 1 }
cout << "答案 = " << l << "\n"; return 0;}点「运行 ▶」看结果
输出会是这样:
第 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 分, 而且你在考场上很难意识到「它不是慢,是根本停不下来」。
如果把 mid 改成上取整:
int mid = l + (r - l + 1) / 2;那么配 l = mid / r = mid - 1 就是对的(这是「找最后一个满足条件的位置」常用的写法)。
关键是:mid 的取整方向和分支的写法必须配套,不能混搭。 记不住就只用第 5 步那一个模板 —— 它能解决这一章和第 9 章的全部问题。
7正解 + 标准库的写法
// 有序数组里查找 —— 二分//// 输入输出和 brute.cpp 完全一样。一次询问只要 O(log n)。//// ============ 这一章只需要背这一个模板 ============//// int l = 1, r = n + 1; // 答案的候选范围是 [l, r],r 多留一格表示「找不到」// 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]// }// // 出来时 l == r,就是答案//// 三个要点,缺一个就会出事://// 1. **循环条件是 l < r,不是 l <= r。** 相等时区间只剩一个数,已经是答案了,// 再进循环就可能原地打转。//// 2. **两个分支必须是 r = mid 和 l = mid + 1。**// 注意不对称:一边是 mid,另一边是 mid+1。// 因为 mid 是**下取整**的,如果写成 l = mid,当 l 和 r 只差 1 时 mid 就等于 l,// 区间一点都没缩小 —— 死循环。(deadloop.cpp 就是这么写的,可以去跑一下。)//// 3. **mid 要写成 l + (r - l) / 2,不要写 (l + r) / 2。**// 后者在 l、r 都接近 int 上限时会溢出成负数。本题不会,但养成习惯。//// 剩下的全部变化,都只是换那个 check:// 找第一个 >= x → check(mid) 是 a[mid] >= x// 找第一个 > x → check(mid) 是 a[mid] > x//// **不要去记「找左边界要不要 +1」这种口诀。** 记住上面这个模板,// 然后每次只问自己一句:「mid 满足条件时,答案还可能在 mid 右边吗?」// 不可能 → r = mid(把 mid 留着)// 可能 → l = mid + 1(把 mid 扔掉)
#include <bits/stdc++.h>using namespace std;
int n;vector<long long> a;
/** 第一个 >= x 的位置,没有就返回 n+1 */int lowerPos(long long x) { int l = 1, r = n + 1; while (l < r) { int mid = l + (r - l) / 2; if (a[mid] >= x) r = mid; // mid 够大了,答案在 mid 或它左边 else l = mid + 1; // mid 太小,答案只能在右边 } return l;}
/** 第一个 > x 的位置,没有就返回 n+1 */int upperPos(long long x) { int l = 1, r = n + 1; while (l < r) { int mid = l + (r - l) / 2; if (a[mid] > x) r = mid; else l = mid + 1; } return l;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> n >> q)) return 0;
a.assign(n + 2, 0); for (int i = 1; i <= n; i++) cin >> a[i];
for (int k = 0; k < q; k++) { long long x; cin >> x; int L = lowerPos(x), U = upperPos(x); cout << L << " " << U << " " << (U - L) << "\n"; } return 0;}点「运行 ▶」看结果
两个函数长得几乎一模一样,唯一的区别是 >= 和 >。这就是模板的价值。
C++ 标准库早就把它们写好了:
// 有序数组里查找 —— 直接用 STL//// 输入输出和 brute.cpp、fast.cpp 完全一样。//// C++ 标准库早就把这两个二分写好了:// lower_bound(first, last, x) 第一个 >= x 的位置// upper_bound(first, last, x) 第一个 > x 的位置// 它们返回的是**迭代器**,减去起始迭代器就得到下标。//// 比赛里能用就用它 —— 少写十行就少十个出错的地方。//// 但请注意两件事:// 1. **必须先排好序**,否则结果是未定义的(不会报错,只会给你一个错答案)。// 2. **对 vector / 数组用没问题,对 set / map 千万别用**:// 那些容器不是连续存储的,标准库的 lower_bound 在上面会退化成 O(n)。// set 自己带成员函数 s.lower_bound(x),用那个。//// 那为什么还要手写一遍(fast.cpp)?// 因为第 9 章的「二分答案」根本没有数组可以给 lower_bound 用 ——// 那里二分的是「答案本身」,check 是一个自己写的函数。// 手写模板必须刻进肌肉记忆,STL 只是顺手的时候用。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, q; if (!(cin >> n >> q)) return 0;
vector<long long> a(n); for (int i = 0; i < n; i++) cin >> a[i]; // 这里用 0 基,方便和迭代器换算
for (int k = 0; k < q; k++) { long long x; cin >> x;
// 迭代器 - begin() 得到 0 基下标,+1 换成题目要求的 1 基 int L = (int)(lower_bound(a.begin(), a.end(), x) - a.begin()) + 1; int U = (int)(upper_bound(a.begin(), a.end(), x) - a.begin()) + 1;
cout << L << " " << U << " " << (U - L) << "\n"; } return 0;}点「运行 ▶」看结果
两个理由:
- 第 9 章的二分答案根本没有数组给你
lower_bound。 那里二分的是「答案本身」,check是一个自己写的函数。手写模板必须会。 lower_bound用在set/map上会退化成 O(n) —— 它们不是连续存储的。那两个容器要用自己的成员函数s.lower_bound(x)。
比赛里能用 STL 就用(少写十行少十个错),但模板要能默写。
8单步看区间怎么减半
盯住两件事:
- 蓝色的候选区间每一轮恰好少一半,一次都没有例外。
- 最右边那个虚线的
∞就是n+1。把 x 改成100播一遍 —— 它最后收敛到那一格。这就是模板里r初值必须是n+1而不是n的原因。
然后切到「第一个 > x」再看一遍:整个过程只有一个符号变了,>= 变成 >。
数组里有三个 4,两种模式的落点正好把它们夹在中间。
// 二分 —— 把每一轮区间怎么收缩打印出来//// 输入:n q / n 个升序整数 / q 行 x(用小数据)// 输出:每次二分的 l、mid、r,以及区间是怎么减半的//// 跑一遍,数一下轮数:n = 16 时最多 5 轮,n = 1000 时最多 10 轮,// n = 10⁹ 时也只要 30 轮。这就是 log 的威力 ——// **数据规模翻十亿倍,工作量只涨 30 倍。**//// 另外注意每一轮的区间长度:16 → 8 → 4 → 2 → 1,一次砍一半,一次都没有例外。// 如果你的二分某一轮区间没变小,那就是死循环(去看 deadloop.cpp)。
#include <bits/stdc++.h>using namespace std;
int main() { int n, q; if (!(cin >> n >> q)) return 0; if (n <= 0 || n > 40) { cout << "这份是用来看过程的,请用 1 <= n <= 40\n"; return 0; }
vector<long long> a(n + 2, 0); for (int i = 1; i <= n; i++) cin >> a[i];
cout << "数组:"; for (int i = 1; i <= n; i++) cout << a[i] << " "; cout << "\n";
for (int k = 0; k < q; k++) { long long x; if (!(cin >> x)) break;
cout << "\n找第一个 >= " << x << " 的位置:\n"; int l = 1, r = n + 1, step = 0; while (l < r) { int mid = l + (r - l) / 2; cout << " 第 " << ++step << " 轮:区间 [" << l << ", " << r << "](还剩 " << (r - l + 1) << " 个候选),mid = " << mid << ",a[" << mid << "] = " << (mid <= n ? to_string(a[mid]) : string("(越界,视为 +∞)"));
if (mid <= n && a[mid] >= x) { cout << " >= " << x << " → 答案在 mid 或左边,r = mid\n"; r = mid; } else if (mid <= n) { cout << " < " << x << " → 答案在 mid 右边,l = mid + 1\n"; l = mid + 1; } else { cout << " → r = mid\n"; r = mid; } } cout << " 收敛到 l = r = " << l; if (l == n + 1) cout << "(数组里所有数都比 " << x << " 小,没有答案)\n"; else cout << ",a[" << l << "] = " << a[l] << "\n"; cout << " 一共 " << step << " 轮(n = " << n << ",理论上限约 log2(" << (n + 1) << ") ≈ " << (int)ceil(log2((double)(n + 1))) << " 轮)\n"; } return 0;}点「运行 ▶」看结果
9★ 对拍验证
把「二分」那一栏换成你自己默写的,再点开始。
// 有序数组里查找 —— 二分//// 输入输出和 brute.cpp 完全一样。一次询问只要 O(log n)。//// ============ 这一章只需要背这一个模板 ============//// int l = 1, r = n + 1; // 答案的候选范围是 [l, r],r 多留一格表示「找不到」// 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]// }// // 出来时 l == r,就是答案//// 三个要点,缺一个就会出事://// 1. **循环条件是 l < r,不是 l <= r。** 相等时区间只剩一个数,已经是答案了,// 再进循环就可能原地打转。//// 2. **两个分支必须是 r = mid 和 l = mid + 1。**// 注意不对称:一边是 mid,另一边是 mid+1。// 因为 mid 是**下取整**的,如果写成 l = mid,当 l 和 r 只差 1 时 mid 就等于 l,// 区间一点都没缩小 —— 死循环。(deadloop.cpp 就是这么写的,可以去跑一下。)//// 3. **mid 要写成 l + (r - l) / 2,不要写 (l + r) / 2。**// 后者在 l、r 都接近 int 上限时会溢出成负数。本题不会,但养成习惯。//// 剩下的全部变化,都只是换那个 check:// 找第一个 >= x → check(mid) 是 a[mid] >= x// 找第一个 > x → check(mid) 是 a[mid] > x//// **不要去记「找左边界要不要 +1」这种口诀。** 记住上面这个模板,// 然后每次只问自己一句:「mid 满足条件时,答案还可能在 mid 右边吗?」// 不可能 → r = mid(把 mid 留着)// 可能 → l = mid + 1(把 mid 扔掉)
#include <bits/stdc++.h>using namespace std;
int n;vector<long long> a;
/** 第一个 >= x 的位置,没有就返回 n+1 */int lowerPos(long long x) { int l = 1, r = n + 1; while (l < r) { int mid = l + (r - l) / 2; if (a[mid] >= x) r = mid; // mid 够大了,答案在 mid 或它左边 else l = mid + 1; // mid 太小,答案只能在右边 } return l;}
/** 第一个 > x 的位置,没有就返回 n+1 */int upperPos(long long x) { int l = 1, r = n + 1; while (l < r) { int mid = l + (r - l) / 2; if (a[mid] > x) r = mid; else l = mid + 1; } return l;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> n >> q)) return 0;
a.assign(n + 2, 0); for (int i = 1; i <= n; i++) cin >> a[i];
for (int k = 0; k < q; k++) { long long x; cin >> x; int L = lowerPos(x), U = upperPos(x); cout << L << " " << U << " " << (U - L) << "\n"; } return 0;}值得故意写错的,每一个都是真实高频错误:
r的初值写成n→ 「答案是 n+1」那种情况永远算不出来l = mid(少了 +1) → 死循环(对拍会报「超时」)while (l <= r)→ 死循环lowerPos里的>=写成>→ 变成了 upper,重复元素时才会暴露 —— 这就是为什么生成器一定要造重复元素mid = (l + r) / 2→ 本题不会炸,但记住它有溢出风险
如果数组里所有元素互不相同,那么 lower 和 upper 只差 1,
把 >= 写成 > 的错误几乎抓不住。
所以生成器把取值压到只有 0~8 —— n=12 的数组里必然有大量重复。 造数据的原则(第 1 章就说过):让「错误的直觉」在你的数据上必定失败。
10二分的适用条件
课本上说「二分用于有序数组」,这句话太窄了。真正的条件是:
存在一个分界点,左边全部不满足,右边全部满足(或者反过来)。
不满足 不满足 不满足 | 满足 满足 满足 满足
↑
分界点有序数组 + 「>= x」这个条件,恰好满足上面这个形状。 但很多题里根本没有数组 —— 只要「答案越大越容易满足」,就能二分。
那就是下一章的全部内容。
11自测
- 洛谷 P2249 查找解析 → —— lower_bound 模板题。先手写一遍,再用 STL 写一遍,对比结果
- 洛谷 P1102 A-B 数对解析 → —— 上一章用双指针做过,现在用「排序 + 两次二分」再做一遍 —— 这题正好是 U-L 的用武之地
- 洛谷 P1873 砍树解析 → —— 二分答案。做不出来很正常,下一章就讲它 —— 但可以先自己试试
- 洛谷 P1024 一元三次方程求解解析 → —— NOIP2001。实数二分,注意精度和循环终止条件(不能用 l < r)
第 9 章二分答案,是二分真正的杀手锏,也是 CSP-J/S 的高频考点。
它的套路很反直觉:不会直接求答案?那就猜一个,再写个函数验证这个猜测行不行。 而「验证」通常比「求解」简单一百倍。
这一章的模板会原封不动地用上,只是 check 从「比大小」变成「一段贪心」。