④ 数据结构与算法 — 第 14 课
check(x) 就能求「最大值最小」。先看最基础的问题:数组 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) |
|---|---|---|
| 第一个 ≥ x | if (a[mid] >= x) r = mid; else l = mid + 1; | 找 3 → 下标 1(第 2 个) |
| 第一个 > x | if (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;
}
| 轮次 | l | r | mid | ok(mid) | 更新后 |
|---|---|---|---|---|---|
| 1 | 1 | 6 | 3 | true | l = 3, r = 6 |
| 2 | 3 | 6 | 4 | true | l = 4, r = 6 |
| 3 | 4 | 6 | 5 | true | l = 5, r = 6 |
| 4 | 5 | 6 | 5 | true | l = 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)重合,区间不再缩小,死循环。
这是二分的第二个用法,也是比赛里更常见的那个。它解决的问题长这样:
这些题直接做很难:你怎么知道最优的分法长什么样?但反过来问就简单多了 —— 「如果答案是 x,能不能做到?」 这个判断往往一遍贪心就够了。
max(a),最大是 sum(a))。x 可行,那么比 x 更宽松的值一定也可行(或严格反过来)。这样才能二分。O(n) 的贪心,总共 O(n log V)。// ① 求「最小可行值」:可行就往下压
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(其实一样,因为循环结束时它们相等)。写之前先想清楚:我要的是「最小」还是「最大」 —— 想错了就是死循环或差一。
把它换成判断题:
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)(一段装完)。
| 轮次 | lo | hi | mid | check(mid):最少需要几段 | 结论 |
|---|---|---|---|---|---|
| 1 | 5 | 16 | 10 | 2 段(4+2+4 / 5+1)≤ 3 ✓ | hi = 10 |
| 2 | 5 | 10 | 7 | 3 段(4+2 / 4 / 5+1)≤ 3 ✓ | hi = 7 |
| 3 | 5 | 7 | 6 | 3 段(4+2 / 4 / 5+1)≤ 3 ✓ | hi = 6 |
| 4 | 5 | 6 | 5 | 5 段(每个数一段)> 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 组不一致。
判断题: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;
}
| 轮次 | lo | hi | mid | check(mid):要搬几块 | 结论 |
|---|---|---|---|---|---|
| 1 | 1 | 25 | 13 | 5 块 > 2 ✗ | hi = 12 |
| 2 | 1 | 12 | 7 | 4 块 > 2 ✗ | hi = 6 |
| 3 | 1 | 6 | 4 | 2 块 ≤ 2 ✓ | lo = 4 |
| 4 | 4 | 6 | 5 | 3 块 > 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。
看到下面这些字眼,先问自己「能不能二分」:
| 题面里的说法 | 改成判断题是什么 |
|---|---|
| 最大化最小值(让最短的那段尽量长) | 「最短能否 ≥ 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 = 30000 | 3.7 ms | 150 组随机 vs 暴力,0 不一致 |
| P2678 二分答案 | L = 109、N = 50000 | 1.9 ms | 200 组随机 vs 暴力,0 不一致 |
mid 的取整方向:l = mid 配上取整,r = mid 配下取整;错了就是死循环mid = l + (r - l) / 2 而不是 (l + r) / 2:后者在 l + r 超过 int 时会溢出lo / hi 必须是「一定不可行 / 一定可行」或者「一定可行 / 一定不可行」,想清楚哪头是哪头if (v > x) return false; 不能省(单个元素比 x 还大时,一段装不下)L - last)最容易漏long long:P1182 的答案能到 109 级、P2678 的 L 也是 109lower_bound 只保证「第一个 ≥ x」,那个位置上的值可能不是 x(P2249 要输出 -1)lower_bound(a, x) 返回的是什么位置?mid 为什么要上取整?check(x) 具体在判断什么?ios::sync_with_stdio(false); cin.tie(nullptr);。用你手写的 lowerBound 交一次,再用 std::lower_bound 交一次,看两者有没有差别。lo 和 hi 各该取什么,再写 check。check 里末尾那一跳。写完自己造两组数据:一组「石头很密」、一组「M = 0」。mid 的取整方向和 l/r 的更新对一遍;check 单独测过吗?拿几个明显的 x 手算一下「应该 true 还是 false」;lo 和 hi 是不是真的一个不可行、一个可行;long long)lowerBound 求「第一个 ≥ x」,找到后还要判等(P2249 的 -1)l = mid 配下取整 → mid 必须上取整;r = mid 配下取整return false,应该 cnt++;对拍 200 组才抓出来check 里会冒出来。
check 就是靠对拍抓出来的错。哪一句看不懂 —— 比如「左闭右开」为什么能省掉 +1、check 的单调性怎么验证 —— 直接问我,我从零再讲一遍。