两个看起来很不一样的问题,答案是同一个。
A 的答案是二分查找:有序 ⇒ 每次比较就能排除一半。B 的答案是二分答案:把“求最优”换成“判定可行”,可行的取值是一段连续的区间,而“是否可行”沿大小方向单调 ⇒ 也能每次砍掉一半。两者都是 O(log) 次判断,log₂10⁶ ≈ 20,log₂4×10⁵ ≈ 19。
给出 n 个不超过 10⁹ 的单调不减非负整数 a₁…aₙ,然后进行 m 次询问。每次询问给出一个整数 q,输出 q 在序列中第一次出现的编号(从 1 开始),没有则输出 −1。
输入:第一行两个整数 n、m(1 ≤ n ≤ 10⁶,1 ≤ m ≤ 10⁵);第二行 n 个整数 aᵢ;第三行 m 个整数,表示每次询问的 q。
输出:一行 m 个整数,用空格隔开。
这道题手写二分只要 8 行,但标准库已经把“第一个不小于 x 的位置”这件事做成了函数。先把它彻底看清:两个函数只差一个符号,却对应完全不同的语义。
| 组成 | 含义 / 格式要求 |
|---|---|
首指针 a + 1 | 搜索区间的左端,包含。 |
尾后指针 a + n + 1 | 右端,不包含。它指向最后一个元素的下一个位置,所以盖住 a[1]..a[n] 要写 a + n + 1,不是 a + n。 |
x | 要找的数。要求 [首, 尾) 这段已按 < 排好序(单调不减即可,允许重复)。 |
返回值 | 指向第一个“≥ x”的元素的迭代器;若区间里所有元素都 < x,返回尾后指针 a+n+1。 |
转成下标 | 减去首地址就是 1-based 下标:int p = lower_bound(a + 1, a + n + 1, x) - a;,p 的取值是 1..n+1。 |
判存在 | 两件事都要查:p <= n && a[p] == x。只查前者会把“第一个比 x 大的数”当成 x。 |
复杂度 | O(log n),大约 ⌈log₂n⌉ 次比较,内部只用 operator<,不要求元素互不相同。 |
int a[7] = {0, 1, 2, 2, 2, 5, 7}; // a[1..6] 是数据,a[0] 空着
int p = lower_bound(a + 1, a + 7, 2) - a; // 第一个 >= 2 -> p = 2
int q = lower_bound(a + 1, a + 7, 3) - a; // 第一个 >= 3 -> q = 5(a[5] = 5)
int r = lower_bound(a + 1, a + 7, 9) - a; // 全都 < 9 -> r = 7(尾后)
// r == 7 已经超出 a[6],所以 a[r] 是越界读!必须先判 r <= 6lower_bound(a, a + n, x) 漏掉了 a[n](右端不含)。这是最高频的错。
它不保证找到的是 x,只保证是第一个 ≥ x 的。想确认相等,必须自己再比一次。
| 组成 | 含义 / 格式要求 |
|---|---|
和 lower_bound 的唯一区别 | 把“≥”换成“>”。其余参数、返回值规则、复杂度完全相同。 |
位置关系 | 两个函数把数组切成三段:[1, lo) 全都 < x,[lo, up) 全都 == x,[up, n] 全都 > x。 |
数个数 | up - lo 就是 x 出现的次数,x 不存在时结果是 0,不用额外判断。 |
判存在 | lo < up 等价于“x 在数组里”。这是最干净的写法。 |
x 比所有元素都大 | 两者都返回 n+1。 |
x 比所有元素都小 | 两者都返回 1。 |
int lo = lower_bound(a + 1, a + n + 1, x) - a; // 第一个 >= x
int up = upper_bound(a + 1, a + n + 1, x) - a; // 第一个 > x
int cnt = up - lo; // x 出现的次数
bool has = (lo < up); // x 是否出现
int le = up - 1; // 最后一个 <= x 的位置upper_bound 返回的是“最后一个 x 的后一位”,不是最后一个 x 本身,减 1 才是。
求“最后一个 ≤ x 的位置”用 upper_bound(...) - 1,比 lower_bound 加判断更省事。
题目要的是第一次出现的位置,重复元素不算新问题——这正是 lower_bound 的定义。
所以整道题只有一句话:p = lower_bound(a+1, a+n+1, q) - a。剩下的全是判断:
p == n + 1:所有元素都小于 q,数组里没有 q,输出 −1。a[p] != q:p 指向的是第一个比 q 大的数,说明 q 不在数组里,输出 −1。p <= n && a[p] == q,靠 && 的短路避免 p = n + 1 时读 a[p]。为什么必须再判一次相等?因为 lower_bound 找的是“第一个不小于 q 的位置”,它对“q 存不存在”一无所知。测试点里一定有“数组里有 5、没有 6,问 6”这种数据。
复杂度 O(m log n):10⁵ × 20 = 2×10⁶ 次比较,输入量 10⁶ 个数,关同步后 1 秒内轻松过。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005; // n ≤ 10⁶,1-based 用到 a[n]
int a[MAXN];
int main() {
ios::sync_with_stdio(false); // 10⁶ 个读入,不关同步很容易 TLE
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; ++i) cin >> a[i];
for (int i = 1; i <= m; ++i) {
int q;
cin >> q;
// 区间 [a+1, a+n+1):正好盖住 a[1]..a[n],右端是尾后指针
int p = lower_bound(a + 1, a + n + 1, q) - a; // 第一个 >= q 的下标
if (i > 1) cout << ' '; // 行末不留多余空格
if (p <= n && a[p] == q) cout << p; // 找到:p 就是第一次出现的位置
else cout << -1; // 全比 q 小,或没有等于 q 的数
}
cout << '\n';
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
const int MAXN = 1000005; | 下标用到 1..10⁶,开 1000000 会在 i = 10⁶ 时越界。多留几个位置。 |
lower_bound(a + 1, a + n + 1, q) | a+1 是第一个元素,a+n+1 是尾后位置。写 a+n 就少了最后一个元素。 |
- a | 指针相减得到“相隔几个元素”,正好是 1-based 下标。这是 C++ 指针减法的语义,不是魔法。 |
p <= n | 先判越界。p 可能等于 n+1,此时 a[p] 是数组末尾之后一格,读它就是越界。 |
a[p] == q | lower_bound 只保证 ≥ q。要确认相等必须自己比。这一句和上一句靠 && 短路连起来才安全。 |
if (i > 1) cout << ' '; | 第一个数前面不加空格,最后一个数后面不留空格。严格评测会卡行末空格。 |
ios::sync_with_stdio(false); | 10⁶ 次读入,关同步之后 cin 快 3~5 倍;不关的话这题很容易卡在输入上。 |
标准库好用,但比赛里经常需要变形:找“最后一个满足条件的”“最小的可行值”“答案在 long long 范围”。这些都得自己写二分。手写二分只有两种正确姿势,先把它们刻下来。
| 组成 | 含义 / 格式要求 |
|---|---|
写法 A:lo <= hi | 闭区间。区间里每个下标都可能是答案;lo == hi 时那个位置还没被检查,所以要用 <=。 |
写法 A:ans = mid | mid 满足条件,先记下来,再去左半边找“更靠前”的满足者。 |
写法 B:hi = n + 1 | 半开区间 [lo, hi)。n+1 是“不存在”的哨兵,让答案域多一个合法取值,省掉 ans 变量。 |
写法 B:hi = mid | mid 本身可能就是答案,必须保留它,只把右端缩到 mid。 |
两者共同的:lo = mid + 1 | mid 一定不满足条件,整个左边(含 mid)都能排除,所以是 +1 不是 mid。 |
铁律 1 | 更新式里出现 hi = mid(右边不 −1),mid 必须向下取整 (lo+hi)>>1。 |
铁律 2 | 更新式里出现 lo = mid(左边不 +1),mid 必须向上取整 (lo+hi+1)>>1。 |
铁律 3 | 两种写法不能混。混用是二分死循环的头号来源,而且它不报错,只是永远不结束。 |
int a[9] = {0, 1, 2, 2, 2, 5, 7, 7, 9}; // a[1..8]
// 写法 B:找第一个 >= 2 的下标
int lo = 1, hi = 8 + 1; // 半开区间 [1, 9]
while (lo < hi) {
int mid = (lo + hi) >> 1; // 向下取整
if (a[mid] >= 2) hi = mid; // mid 可能是答案,留着(不 -1)
else lo = mid + 1; // mid 不可能是答案,扔掉
}
cout << lo << "\n"; // 2
// 写法 A:同样的目标,另一种记录方式
int l = 1, r = 8, ans = -1;
while (l <= r) {
int mid = (l + r) >> 1;
if (a[mid] >= 2) { ans = mid; r = mid - 1; }
else l = mid + 1;
}
cout << ans << "\n"; // 2写法 B 里 mid 永远小于 hi,所以 a[mid] 读不到哨兵 a[n+1],哨兵是安全的。
lo + hi 可能溢出 int:hi 到 2×10⁹ 时两数相加变负数,mid 变负 → 数组越界 RE。改写成 lo + (hi - lo) / 2。
| 更新式 | mid 必须 | 循环条件 | 写错的症状 |
|---|---|---|---|
| lo = mid + 1; hi = mid; | 向下 (lo+hi)>>1 | lo < hi | 向上取整时 hi = mid 不缩小 → 死循环 TLE |
| lo = mid; hi = mid - 1; | 向上 (lo+hi+1)>>1 | lo < hi | 向下取整时 lo = mid 不缩小 → 死循环 TLE |
| lo = mid + 1; hi = mid - 1; | 两种都对 | lo <= hi | 无(最稳的一套) |
二分查找是在数组上二分;二分答案是在答案的取值上二分。两者的代码骨架一模一样,区别只在于比较的对象:前者比 a[mid] 与 x,后者比 check(mid) 与 true。
有 N 棵树,第 i 棵高 hᵢ 米。把锯片升到整数高度 H,所有高于 H 的部分被锯下,于是得到 Σ max(0, hᵢ − H) 米木材。求最大的整数 H,使得到的木材至少 M 米。
输入:第一行 N、M(1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹);第二行 N 个整数表示树高(≤ 4×10⁵),保证所有树高之和大于 M。
输出:一行一个整数,即锯片能设置的最大高度。
不要一上来就写二分。先把题目里的“求最值”翻译成一句判断:锯片设成 H 时,木头够不够 M?这一个函数写完,二分部分就是模板。
bool ok(int H) {
long long sum = 0;
for (int i = 1; i <= n; ++i)
if (a[i] > H) sum += a[i] - H; // 只有比锯片高的树贡献木材
return sum >= M;
}然后看单调方向:H 越大,每棵树贡献的非负差值越小,总和越小。所以“够 M”这个性质是前半段为真、后半段为假——H 从 0 增大时,先一直可行,某一米开始不可行,而且再也不会变回可行。
题目要最大的可行 H,也就是那个“真区间的右端点”。二分时 mid 可行就说明答案可能是它或者更大,于是 ans = mid 记下来、lo = mid + 1 往右试;mid 不可行就说明 mid 和它右边全废,hi = mid − 1。
复杂度:值域 ≤ 4×10⁵,二分开销 ⌈log₂(4×10⁵)⌉ = 19 次;每次 check 扫 n = 10⁶ 个数,总共 1.9×10⁷ 次加法。朴素枚举 H 是 4×10¹¹ 次,差 4 个数量级。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
int a[MAXN];
int n;
long long M;
// check(H):锯片升到 H 米,能不能拿到至少 M 米木材
bool ok(int H) {
long long sum = 0;
for (int i = 1; i <= n; ++i) {
if (a[i] > H) sum += a[i] - H; // 高过锯片的部分才被锯下
if (sum >= M) return true; // 已经够了,后面不用看
}
return sum >= M;
}
int main() {
ios::sync_with_stdio(false); // 10⁶ 个读入,而且要被扫 19 遍
cin.tie(nullptr);
cin >> n >> M;
int mx = 0;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
if (a[i] > mx) mx = a[i];
}
int lo = 0, hi = mx, ans = 0; // 答案一定在 [0, mx] 内
while (lo <= hi) {
int mid = lo + (hi - lo) / 2; // 与 (lo+hi)/2 等价,但不会溢出
if (ok(mid)) { ans = mid; lo = mid + 1; } // 可行:记下来,再试着升高
else { hi = mid - 1; } // 不可行:只能降
}
cout << ans << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
bool ok(int H) | 判定函数独立成函数。这样二分部分与题目彻底分开,换题只改 ok。 |
if (a[i] > H) sum += a[i] - H; | 锯片以上的部分才被锯下。写成 a[i] - H 会把负贡献也算进去。 |
if (sum >= M) return true; | 早退。10⁶ × 4×10⁵ 最坏是 4×10¹¹,虽然 long long 装得下,但提前跳出能让 check 平均只扫一小段。 |
long long sum | 4×10¹¹ 超过 int 上限 2.1×10⁹。用 int 会溢出成负数,于是 check 永远返回 false,答案恒为 0。 |
int lo = 0, hi = mx; | 答案域的下界取 0(再低没有意义),上界取最高的树(再高一根木头都锯不到)。下界必须 ≤ 真答案,上界必须 ≥ 真答案,否则二分找不到。 |
int mid = lo + (hi - lo) / 2; | 等价于 (lo+hi)/2,但前者不会溢出。hi 到 2×10⁹ 时 lo+hi 会爆 int。 |
if (ok(mid)) { ans = mid; lo = mid + 1; } | mid 可行不代表它是最大,只代表它是“可行集合里的一个”,所以先记下,再往右找更大的。 |
else { hi = mid - 1; } | mid 不可行,它和它右边全部不可行(单调性),一刀砍掉。 |
cout << ans; | 循环结束时 ans 是最后一个可行的 mid,也就是最大的可行 H。 |
| 题目问法 | check(v) 为真的含义 | 真的时候怎么走 | 最后返回 |
|---|---|---|---|
| 最大可行值(砍树、木材加工) | 取 v 可行 | ans = mid; lo = mid + 1 | ans |
| 最小可行值(数列分段、跳石头) | 取 v 可行 | ans = mid; hi = mid - 1 | ans |
| 第一个 ≥ x 的位置 | a[mid] ≥ x | ans = mid; hi = mid - 1 | ans |
| 最后一个 ≤ x 的位置 | a[mid] ≤ x | ans = mid; lo = mid + 1 | ans |
WA:偶尔少算一个。—— 区间右端写成 a + n,漏掉 a[n]。做“找第一个 ≥ x”时有时看不出问题(后面还有 a[p] == q 兜着),一改成计数 up - lo 就立刻错。凡是 n 个元素都要写 a + n + 1。
RE:随机崩或答案乱。—— 直接解引用返回值。所有元素都 < x 时它等于尾后指针,读它就是越过数组末尾。先判 p <= n。
WA:输出一个位置,正确答案是 −1。—— 只判了 p <= n,没判 a[p] == q。数组里有比 q 大的数时就会漏判。
TLE:程序不结束。—— 手写二分混用了 lo = mid 和 hi = mid - 1,或者 hi = mid 配了向上取整。区间长度不再缩小,循环永远跑。
WA:结果毫无规律。—— 忘了数组必须有序。二分在无序数组上不报错,只是每次都往错的方向排除。
TLE。—— n = 10⁶、m = 10⁵ 却没写 ios::sync_with_stdio(false)。
WA:答案永远是 0 或上界。—— check 的方向写反了。验证方法:手算 check(lo) 与 check(hi),确认一个真一个假;如果两个同真假,说明区间或方向错了。
WA:答案恒为 0,且怎么改区间都没用。—— check 里用 int 累加,4×10¹¹ 溢出成负数,于是永远“不够”。这一类 bug 只在最大数据上出现,小样例全过。
WA:总是差 1。—— 返回 lo / hi / ans 选错,或 ans 忘了在可行分支里更新。自查:把区间缩到长度 1,手推一遍 mid、赋值、返回,看是否正好落在答案上。
TLE:卡死不结束。—— while (lo < hi) 里写了 lo = mid 却没配上取整。
RE:数组越界。—— mid 用 (lo + hi) / 2 且 hi 接近 2×10⁹,溢出成负数后拿去当下标。
关掉页面,从空文件把 P2249 敲一遍并提交。
改一改 ①:把程序改成输出“q 在序列里出现了几次”——up - lo,其中 up 用 upper_bound。题目立刻变成一道统计题。
改一改 ②:在 ① 的基础上把两处 a + n + 1 都改成 a + n,输入 3 1 / 1 2 2 / 2:正确答案是 2,改完输出 1——最后一个元素根本没进搜索区间。
改一改 ③:把 lower_bound 换成手写的半开二分(FN 卡里的写法 B),答案必须完全一致。
改一改 ④:P1873 里把 lo + (hi - lo) / 2 改成 (lo + hi) / 2,再把 hi 的上界改成 2×10⁹ 跑一次,观察是否进入死循环或数组越界。
n 个有序元素,每次比较把候选砍成一半:n → n/2 → n/4 → … → 1。需要 k 次使 n/2ᵏ ≤ 1,即 k ≥ log₂n。最坏比较次数精确地是 ⌈log₂(n+1)⌉。
| n | 10 | 10³ | 10⁶ | 10⁹ |
|---|---|---|---|---|
| 二分比较次数 | 4 | 10 | 20 | 30 |
| 线性扫描次数 | 10 | 1000 | 10⁶ | 10⁹ |
这就解释了为什么 n = 10⁶、m = 10⁵ 的查找题必须用二分:10⁵ × 20 = 2×10⁶ 次比较,而线性做法是 10¹¹ 次。
不变式(半开写法的正确性):循环开始时,真答案一定落在 [lo, hi] 这一段里(把 n+1 当作“不存在”的合法答案)。
a[mid] >= x:mid 满足条件,答案 ≤ mid,所以 hi = mid,答案仍在 [lo, mid] 内。lo = mid + 1,答案仍在 [mid+1, hi] 内。这段证明顺便解释了铁律:如果把 mid 改成上取整,hi = mid 在 hi = lo + 1 时会令 mid = hi,区间长度不减,证明的第三步就失效了——程序表现为死循环。
二分答案的总代价 = O(log(hi − lo) × cost(check))。P1873 是 19 × 10⁶ ≈ 2×10⁷,P1182 是 44 × 10⁵ ≈ 4×10⁶,都在 1 秒的余量内。这也提醒你:check 必须是 O(n) 级别的,如果 check 里再套一层 sort 或二分,复杂度会乘上 log,很可能就过不去了。
单调性是硬前提。形如“真真…真假假…假”的判定才可二分。如果判据是“总和恰好等于 M”,可行集合是散点,二分直接失效,只能枚举或换思路。拿到题先问自己:答案变大时,可行性是一直真然后一直假,还是会来回跳?
实数域:二分实数时不写 while (r - l > eps),因为浮点误差可能让它永远不结束;固定循环 100 次,区间长度缩到 2⁻¹⁰⁰,精度早就够了。
给出一个长度为 N 的正整数数列 A₁…AN,要把它分成 M 段(M ≤ N),每段必须连续。设每段内数字之和为 Si,求 max(Si) 的最小值。
输入:第一行两个正整数 N、M;第二行 N 个空格隔开的非负整数 Ai(1 ≤ N ≤ 10⁵,M ≤ N,Ai < 10⁸,答案不超过 10⁹)。
输出:一行一个正整数,即每段和最大值的最小值。
题目问的是“最大值最小”,这是二分答案最典型的信号。先把“求最小”换成判断:给定一个上限 v,能不能分成不超过 M 段,使每段和都不超过 v?
判定一个 v 是否可行,从左往右贪心即可:能塞进当前段就塞,塞不下就另起一段。这样得到的段数是所有分法里最少的。
v 的取值下界不能随便设:某个 Ai 单独一段都超过 v 时,无论怎么分都不行,所以下界至少是 max(Ai)。
二分答案 x = “每段和的上限”。问题变成:最小的可行 x 是多少。
check(x):从左往右扫,cur 记当前段的和。若 cur + Ai ≤ x 就并进去;否则开新段(cnt++,cur = Ai)。最后 cnt ≤ M 就可行。
为什么贪心是最优的:任何合法分法的第一段右端点都不会超过贪心的第一段右端点(贪心尽量往右塞),归纳下去,贪心的段数 ≤ 任何分法的段数。所以“贪心段数 ≤ M”与“存在一种分法不超过 M 段”等价——这就是第 16 节的交换论证。
单调性:x 越大越容易分,check 形如“假假…假真真…真”。要求最小的可行值,所以真的时候 hi = mid(保留 mid),假的时候 lo = mid + 1。
下界 lo = max(Aᵢ)(否则单个元素都装不下),上界 hi = ΣAᵢ(全部塞进一段,一定可行)。答案是 lo 收敛到的位置,O(N log Σ) ≈ 10⁵ × 44 ≈ 4.4×10⁶。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, m;
long long a[MAXN];
// check(limit):每段和都不超过 limit 时,最少要分成几段?不超过 m 段就可行
bool ok(long long limit) {
int cnt = 1; // 至少一段
long long cur = 0; // 当前这一段已经装了多少
for (int i = 1; i <= n; ++i) {
if (cur + a[i] <= limit) {
cur += a[i]; // 塞得下就塞:段数越少越容易满足 "<= m"
} else {
++cnt; // 塞不下,a[i] 自己开一段
cur = a[i]; // 注意不是 0,a[i] 已经在这一段里了
if (cnt > m) return false; // 段数只会增不会减,提前剪枝
}
}
return cnt <= m;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
long long lo = 0, hi = 0;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
hi += a[i]; // 上界:全部放进一段,一定可行
lo = max(lo, a[i]); // 下界:至少要装得下最大的那个数
}
while (lo < hi) { // 半开写法:答案域 [lo, hi]
long long mid = lo + (hi - lo) / 2;
if (ok(mid)) hi = mid; // 可行:mid 可能就是答案,保留
else lo = mid + 1; // 不可行:mid 及更小全部排除
}
cout << lo << "\n"; // 循环结束时 lo == hi == 最小的可行上限
return 0;
}| 片段 | 作用 |
|---|---|
int cnt = 1; long long cur = 0; | 段数从 1 开始:n ≥ 1 时至少有一段。cur 是当前段已装的和。 |
if (cur + a[i] <= limit) cur += a[i]; | 能塞就塞。这一步的贪心性质是后面所有论证的基础。 |
else { ++cnt; cur = a[i]; } | 塞不下才开新段,并把 a[i] 放进新段(重置成 a[i],不是 0)。 |
if (cnt > m) return false; | 段数只增不减,一旦超过 m 立刻返回,省掉后半段扫描。 |
lo = max(lo, a[i]); | 下界的正确来源。设成 0 或 1 不会错但会多跑几轮;设成平均值会漏解。 |
while (lo < hi) ... hi = mid; | 半开写法。mid 保留在区间里,所以 mid 必须向下取整,否则 lo+1 == hi 时死循环。 |
long long mid / a[] / cur | mid 最大到 ΣAᵢ ≈ 10¹³,cur 也可能很大,一律 long long。 |
cout << lo; | 第一个可行的上限就是答案;此时 lo == hi,返回谁都可以。 |
hi = mid 写成 hi = mid - 1 又保留半开区间:漏掉答案,输出比真值小 1。