Lesson 0414 · 二分:二分查找与二分答案

④ 数据结构与算法 — 第 14 课

上节课:最小生成树 —— Kruskal 靠「排序 + 并查集」,Prim 靠「每次挑最近的」。两个都是贪心,正确性来自割性质。
这节课:二分的两副面孔。二分查找是在有序数组里找位置(P2249);二分答案是在答案空间里找最优值 —— 后者更值钱,因为它能把「求最大值最小」这类看着没法下手的题,变成「判断 x 行不行」加十几次二分(P1182、P2678)。
三句话速览

1. 二分查找:在有序数组里找位置

先看最基础的问题:数组 a 单调不减,要找出第一个大于等于 x 的位置。

暴力是扫一遍 O(n);二分是每次看中间那个数,跟 x 比一下,砍掉一半,O(log n)。n = 106 时,扫一遍要 106 次,二分只要 20 次。

int lowerBound(const vector<int>& a, int x) {   // 第一个 >= x 的下标;都不满足返回 n
    int l = 0, r = (int)a.size();              // 左闭右开 [l, r)
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (a[mid] >= x) r = mid;              // mid 可能就是答案,别丢
        else l = mid + 1;                      // mid 肯定不是,跳过
    }
    return l;
}
为什么是「左闭右开」:r 表示「候选区间的右边界」,a[r] 不参与比较。这样 r = mid 和 l = mid + 1 都不需要 ±1 的额外判断,写起来最不容易错。

三个常用变体

要什么写法例子(a = 1 3 3 3 5 7)
第一个 ≥ xif (a[mid] >= x) r = mid; else l = mid + 1;找 3 → 下标 1(第 2 个)
第一个 > xif (a[mid] > x) r = mid; else l = mid + 1;找 3 → 下标 4(值 5)
最后一个 ≤ x先求「第一个 > x」再减 1找 3 → 下标 3(第 4 个)

P2249 要的就是「第一个 ≥ x」,然后再检查 a[pos] == x:相等说明找到了,输出 pos + 1(题面是 1-based);否则输出 -1。

实测(数组 1 3 3 3 5 7):

P2249 查 3 → 2
P2249 查 6 → -1

死循环是怎么来的

二分最常见的 bug 不是答案错,是死循环。看这个错误写法(求「最后一个满足条件的」时很多人这么写):

while (l < r) {
    int mid = (l + r) / 2;      // 下取整
    if (ok(mid)) l = mid;       // ← 问题在这:l 可能等于 mid,永远不前进
    else r = mid - 1;
}
轮次lrmidok(mid)更新后
1163truel = 3, r = 6
2364truel = 4, r = 6
3465truel = 5, r = 6
4565truel = 5, r = 6 —— 卡住了

l = 5, r = 6 时 mid = (5+6)/2 = 5,l = mid = 5 什么都没变。修法是 mid 上取整:

int mid = (l + r + 1) / 2;      // 或者 l + (r - l + 1) / 2
记这条就够:
更新写 l = mid(找「最大可行」)→ mid 必须上取整;
更新写 r = mid(找「最小可行」)→ mid 下取整。
否则 mid 会跟 l(或 r)重合,区间不再缩小,死循环。

2. 二分答案:把「求最优」换成「判对错」

这是二分的第二个用法,也是比赛里更常见的那个。它解决的问题长这样:

这些题直接做很难:你怎么知道最优的分法长什么样?但反过来问就简单多了 —— 「如果答案是 x,能不能做到?」 这个判断往往一遍贪心就够了。

二分答案的三件套

两套模板

// ① 求「最小可行值」:可行就往下压
long long l = low, r = high;
while (l < r) {
    long long mid = l + (r - l) / 2;        // 下取整
    if (check(mid)) r = mid;                // 可行 → 答案不会比 mid 大
    else l = mid + 1;
}
// 结束时 l == r 就是答案

// ② 求「最大可行值」:可行就往上推
long long l = low, r = high;
while (l < r) {
    long long mid = l + (r - l + 1) / 2;    // 上取整,别忘
    if (check(mid)) l = mid;
    else r = mid - 1;
}

两套只差三处:区间的移动方向、mid 的取整、以及最后取 l 还是 r(其实一样,因为循环结束时它们相等)。写之前先想清楚:我要的是「最小」还是「最大」 —— 想错了就是死循环或差一。

3. P1182 数列分段:check 就是「贪心分段」

题面要点:N ≤ 105,Ai < 108,答案不超过 109。要把数列切成 M 段连续的段,使「每段和的最大值」最小。

把它换成判断题:

check(x):能不能切成「不超过 M 段」,让每段的和都不超过 x?

做法是贪心:从左往右扫,能往当前段里塞就塞,塞不下了就开新的一段。这样段数最少 —— 如果连「段数最少」都超过 M,那说明 x 太小。

bool check(long long x) {
    int cnt = 1;                 // 至少一段
    long long sum = 0;
    for (long long v : a) {
        if (v > x) return false; // 单个元素都比 x 大,一段装不下
        if (sum + v <= x) sum += v;
        else { cnt++; sum = v; }
    }
    return cnt <= m;
}

二分的区间:lo = max(a)(每段至少装一个数),hi = sum(a)(一段装完)。

样例逐轮推演(a = 4 2 4 5 1,分 3 段)

轮次lohimidcheck(mid):最少需要几段结论
1516102 段(4+2+4 / 5+1)≤ 3 ✓hi = 10
251073 段(4+2 / 4 / 5+1)≤ 3 ✓hi = 7
35763 段(4+2 / 4 / 5+1)≤ 3 ✓hi = 6
45655 段(每个数一段)> 3 ✗lo = 6

四轮之后 lo = hi = 6,答案 6。注意第 3 轮:check(6) 说「3 段刚好够」,于是把上界压到 6;第 4 轮 check(5) 就要 5 段了,说明 5 不行。

实测(N = 105、M = 30000 的随机数据):3.7 ms;另外 150 组随机小数据与暴力枚举所有切分方式对拍,0 组不一致。

4. P2678 跳石头:check 是「贪心搬石头」

题面要点:河道长 L ≤ 109,中间 N ≤ 50000 块石头,最多搬走 M 块,让最短跳跃距离最大。起点 0、终点 L 不能被搬走。

判断题:check(x):能不能通过搬走 ≤ M 块石头,让每一跳都不少于 x?

贪心:从起点出发,能跳到 x 远就跳;跳不过去就把这块石头搬走。最后别忘检查最后一跳。

bool check(long long x) {
    int cnt = 0;             // 搬走了几块
    long long last = 0;      // 上一块「保留下来」的石头位置
    for (int i = 1; i <= n; i++) {
        if (d[i] - last < x) cnt++;      // 距上一块太近 → 搬走它
        else last = d[i];                // 够远 → 保留,作为新的落脚点
    }
    if (L - last < x) cnt++;             // 最后一跳不够 → 把最后保留的那块也搬走
    return cnt <= m;
}

样例逐轮推演(L = 25,石头 2 11 14 17 21,最多搬 2 块)

轮次lohimidcheck(mid):要搬几块结论
1125135 块 > 2 ✗hi = 12
211274 块 > 2 ✗hi = 6
31642 块 ≤ 2 ✓lo = 4
44653 块 > 2 ✗hi = 4

四轮后 lo = hi = 4,答案 4。注意这里 mid 是上取整((l + r + 1) / 2),因为要求的是「最大可行值」。

这个 check 我第一版写错了,是对拍抓出来的
我一开始把最后那一跳写成了 if (L - last < x) return false; —— 意思是「末尾跳不过去就直接判不可行」。这是错的:末尾跳不过去时,正确做法是把「最后保留的那块石头」也搬走(cnt++),而不是直接否决。
反例:L = 23、石头 7 17、M = 1、x = 7。搬走 17 之后两跳是 7 和 16,完全可行;但「保留 17」的贪心会算出末尾只有 6,于是错误地判了不行,答案从 7 掉到 6。
改成 cnt++ 之后,200 组随机小数据与「枚举所有搬法」的暴力对拍,0 组不一致。

实测(L = 109、N = 50000、M = 10000 的随机数据):1.9 ms。

5. 什么时候该想到二分答案

看到下面这些字眼,先问自己「能不能二分」:

题面里的说法改成判断题是什么
最大化最小值(让最短的那段尽量长)「最短能否 ≥ x」
最小化最大值(让最贵的那段尽量便宜)「最大能否 ≤ x」
求第 k 大 / 第 k 小「≥ x 的个数是否 ≥ k」
「至少 / 至多 M 次操作」「以 x 为目标需要几次操作,是否 ≤ M」

和前面几课的关系:check 里面往往就是前几课的东西 —— P1182 的 check 是贪心,P2678 的 check 也是贪心,有些题(比如「二分 + 判连通」)的 check 会用到并查集或 BFS。二分本身很简单,难点永远在 check。

实测汇总

题规模耗时正确性验证
P2249 二分查找n = 106、m = 105手写 13.8 ms / std::lower_bound 12.9 ms两者结果完全一致
P1182 二分答案N = 105、M = 300003.7 ms150 组随机 vs 暴力,0 不一致
P2678 二分答案L = 109、N = 500001.9 ms200 组随机 vs 暴力,0 不一致

6. 坑点清单

7. 小测

Q1. lower_bound(a, x) 返回的是什么位置?
Q2. 求「最大可行值」时,mid 为什么要上取整?
Q3. P1182 里 check(x) 具体在判断什么?
Q4. 二分答案能用,最关键的前提是什么?
Q5. P2678 的 check 里,最后一跳不够长时该怎么办?

8. 动手练习

  1. P2249 查找(先写这题)—— n ≤ 106、m ≤ 105,输入输出量大,记得 ios::sync_with_stdio(false); cin.tie(nullptr);。用你手写的 lowerBound 交一次,再用 std::lower_bound 交一次,看两者有没有差别。
  2. P1182 数列分段 —— 二分答案入门。先想清楚 lo 和 hi 各该取什么,再写 check。
  3. P2678 跳石头 —— 和 P1182 是同一个套路,难的只有 check 里末尾那一跳。写完自己造两组数据:一组「石头很密」、一组「M = 0」。
卡住时的自查顺序
① 二分会不会死循环?把 mid 的取整方向和 l/r 的更新对一遍;
② check 单独测过吗?拿几个明显的 x 手算一下「应该 true 还是 false」;
③ 区间端点对不对?lo 和 hi 是不是真的一个不可行、一个可行;
④ 答案类型够不够?(long long)
⑤ 还不行就把代码贴过来,我按行给你看 —— 老规矩,先让你猜「这组输入应该输出什么」。

本课小结

下一课预告
0415 前缀和 / 差分 / 单调栈 —— 三个「把 O(n) 变成 O(1)」的小技巧:前缀和让区间求和变成两次减法;差分让区间加变成两个点;单调栈找「左/右边第一个更小(大)的元素」。它们在很多题的 check 里会冒出来。
这一课的代码都真的编译跑过了:两份样例的逐轮二分过程是程序打印出来的(P1182 四轮、P2678 四轮);P1182 用 150 组、P2678 用 200 组随机小数据与暴力对拍,都是 0 组不一致 —— 其中 P2678 的第一版 check 就是靠对拍抓出来的错。哪一句看不懂 —— 比如「左闭右开」为什么能省掉 +1、check 的单调性怎么验证 —— 直接问我,我从零再讲一遍。

← 上一课:最小生成树(Kruskal / Prim) · 课程对照表 · 算法总结

书籍 《算法 (第4版)》第 1.1 节(二分查找)· 《挑战程序设计竞赛》第 3 章(二分探索)

在线 OI-Wiki · 二分 · P2249 · P1182 · P2678