← 目录 第 27 / 60 节 全部 60 节

27 · 二分查找与二分答案

把“找”变成“排除”:数组有序时用 lower_bound / upper_bound 一次砍掉一半,答案的可行性单调时把最优化问题转成判定问题再二分。核心是三件事——语义、mid 取整、check 与区间。
普及/提高-预计 70 分钟二分单调性check 函数
本节出现的新函数 / 新语法(先看懂再用)

两个看起来很不一样的问题,答案是同一个。

A 的答案是二分查找:有序 ⇒ 每次比较就能排除一半。B 的答案是二分答案:把“求最优”换成“判定可行”,可行的取值是一段连续的区间,而“是否可行”沿大小方向单调 ⇒ 也能每次砍掉一半。两者都是 O(log) 次判断,log₂10⁶ ≈ 20,log₂4×10⁵ ≈ 19。

二分的本质不是“找”,是“排除”:每一次判断都要让你能一次性丢掉一半候选。丢掉一半的前提是——候选是有序的。
例题 · 【深基13.例1】查找洛谷 P2249普及-去洛谷做这题 ↗

给出 n 个不超过 10⁹ 的单调不减非负整数 a₁…aₙ,然后进行 m 次询问。每次询问给出一个整数 q,输出 q 在序列中第一次出现的编号(从 1 开始),没有则输出 −1。

输入:第一行两个整数 n、m(1 ≤ n ≤ 10⁶,1 ≤ m ≤ 10⁵);第二行 n 个整数 aᵢ;第三行 m 个整数,表示每次询问的 q。

输出:一行 m 个整数,用空格隔开。

输入: 11 3 1 3 3 3 5 7 9 11 13 15 17 1 3 6 输出: 1 2 -1

这道题手写二分只要 8 行,但标准库已经把“第一个不小于 x 的位置”这件事做成了函数。先把它彻底看清:两个函数只差一个符号,却对应完全不同的语义。

新函数拆解 · lower_bound(第一个 ≥ x 的位置)
lower_bound(首指针, 尾后指针, 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 <= 6
注意

lower_bound(a, a + n, x) 漏掉了 a[n](右端不含)。这是最高频的错。

它不保证找到的是 x,只保证是第一个 ≥ x 的。想确认相等,必须自己再比一次。

新函数拆解 · upper_bound(第一个 > x 的位置)
upper_bound(首指针, 尾后指针, 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 加判断更省事。

例题怎么想:把“找 x”拆成“找位置 + 验证”

题目要的是第一次出现的位置,重复元素不算新问题——这正是 lower_bound 的定义。

所以整道题只有一句话:p = lower_bound(a+1, a+n+1, q) - a。剩下的全是判断:

为什么必须再判一次相等?因为 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] == qlower_bound 只保证 ≥ q。要确认相等必须自己比。这一句和上一句靠 && 短路连起来才安全。
if (i > 1) cout << ' ';第一个数前面不加空格,最后一个数后面不留空格。严格评测会卡行末空格。
ios::sync_with_stdio(false);10⁶ 次读入,关同步之后 cin 快 3~5 倍;不关的话这题很容易卡在输入上。

标准库好用,但比赛里经常需要变形:找“最后一个满足条件的”“最小的可行值”“答案在 long long 范围”。这些都得自己写二分。手写二分只有两种正确姿势,先把它们刻下来。

新函数拆解 · 手写二分(两种写法与 mid 取整铁律)
写法 A:while (lo <= hi) 闭区间 + ans 记录 写法 B:while (lo < hi) 半开区间 + 哨兵
组成含义 / 格式要求
写法 A:lo <= hi闭区间。区间里每个下标都可能是答案;lo == hi 时那个位置还没被检查,所以要用 <=。
写法 A:ans = midmid 满足条件,先记下来,再去左半边找“更靠前”的满足者。
写法 B:hi = n + 1半开区间 [lo, hi)。n+1 是“不存在”的哨兵,让答案域多一个合法取值,省掉 ans 变量。
写法 B:hi = midmid 本身可能就是答案,必须保留它,只把右端缩到 mid。
两者共同的:lo = mid + 1mid 一定不满足条件,整个左边(含 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)>>1lo < hi向上取整时 hi = mid 不缩小 → 死循环 TLE
lo = mid; hi = mid - 1;向上 (lo+hi+1)>>1lo < hi向下取整时 lo = mid 不缩小 → 死循环 TLE
lo = mid + 1; hi = mid - 1;两种都对lo <= hi无(最稳的一套)
记法:谁不 ±1,mid 就朝另一边取整。hi = mid 配下取整,lo = mid 配上取整。记不住就统一用 lo = mid + 1 / hi = mid - 1 / while (lo <= hi),再加一个 ans 变量。

二分查找是在数组上二分;二分答案是在答案的取值上二分。两者的代码骨架一模一样,区别只在于比较的对象:前者比 a[mid] 与 x,后者比 check(mid) 与 true。

例题 · EKO / 砍树洛谷 P1873普及/提高-去洛谷做这题 ↗

有 N 棵树,第 i 棵高 hᵢ 米。把锯片升到整数高度 H,所有高于 H 的部分被锯下,于是得到 Σ max(0, hᵢ − H) 米木材。求最大的整数 H,使得到的木材至少 M 米。

输入:第一行 N、M(1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹);第二行 N 个整数表示树高(≤ 4×10⁵),保证所有树高之和大于 M。

输出:一行一个整数,即锯片能设置的最大高度。

输入: 4 20 20 15 10 17 输出: 10 (H = 10 时得到 10+5+0+7 = 22 ≥ 20;H = 11 时只有 19)
例题怎么想:先把 check 写出来,再去二分

不要一上来就写二分。先把题目里的“求最值”翻译成一句判断:锯片设成 H 时,木头够不够 M?这一个函数写完,二分部分就是模板。

check 函数:O(n),与最优值无关
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 sum4×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 + 1ans
最小可行值(数列分段、跳石头)取 v 可行ans = mid; hi = mid - 1ans
第一个 ≥ x 的位置a[mid] ≥ xans = mid; hi = mid - 1ans
最后一个 ≤ x 的位置a[mid] ≤ xans = mid; lo = mid + 1ans
二分查找的 6 种典型错法(含症状)

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)。

二分答案的 5 种典型错法(含症状)

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⁹ 跑一次,观察是否进入死循环或数组越界。

数学补给 · 二分为什么只要 log:判定树与不变式

n 个有序元素,每次比较把候选砍成一半:n → n/2 → n/4 → … → 1。需要 k 次使 n/2ᵏ ≤ 1,即 k ≥ log₂n。最坏比较次数精确地是 ⌈log₂(n+1)⌉。

n1010³10⁶10⁹
二分比较次数4102030
线性扫描次数10100010⁶10⁹

这就解释了为什么 n = 10⁶、m = 10⁵ 的查找题必须用二分:10⁵ × 20 = 2×10⁶ 次比较,而线性做法是 10¹¹ 次。

不变式(半开写法的正确性):循环开始时,真答案一定落在 [lo, hi] 这一段里(把 n+1 当作“不存在”的合法答案)。

这段证明顺便解释了铁律:如果把 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⁻¹⁰⁰,精度早就够了。

举一反三 · 想清楚这些再往下走
  1. “第一个 > x 的位置”和“最后一个 ≤ x 的位置”差几?能不能用其中一个算出另一个?如果有 10⁵ 次询问、每次都要答这两个数,总复杂度是多少?
  2. 数组是“先严格递增、再严格递减”(单峰),还能二分吗?应该拿 a[mid] 和 a[mid+1] 比,还是和 x 比?如果是“先减后增”,要改哪一处?
  3. 求一个无序数组的第 k 小数,能不能二分答案?check(mid) 要做什么,复杂度是多少?它和 nth_element(第 13 节)比,谁更快?
  4. 二分答案的 check 如果是 O(n log n),总复杂度变成多少?能不能先把数据排序一次,让 check 降到 O(n)?
  5. 如果答案不是整数,而是一个实数(比如“最大的平均收益”),二分要不要改?为什么通常固定循环 100 次而不是写 while (r − l > 1e−8)?
  6. 数组要支持动态插入、删除,还要能“找第一个 ≥ x”。二分还成立吗?需要换成什么结构?
自测:能全部回答再进下一节
挑战题 · 数列分段 Section II
洛谷 P1182普及/提高-去洛谷做这题 ↗

给出一个长度为 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[] / curmid 最大到 ΣAᵢ ≈ 10¹³,cur 也可能很大,一律 long long。
cout << lo;第一个可行的上限就是答案;此时 lo == hi,返回谁都可以。
这题最容易错的地方
  • cnt 初值写成 0:最后比真实段数少 1,check 变宽松,答案偏小(有时差 1,有时差很多)。
  • 开新段时 cur 重置成 0:漏掉 a[i],check 认为可行,答案偏小。
  • 段内累加用 int:limit 可以到 ΣAᵢ ≈ 10¹³,cur 溢出成负数,check 结果全乱。
  • 下界设得比真答案大(例如写成 ΣAᵢ/M 的向上取整):二分只在 [lo, hi] 里找,真答案根本不在区间内,输出必然偏大、甚至是个不可行的值。
  • 把 hi = mid 写成 hi = mid - 1 又保留半开区间:漏掉答案,输出比真值小 1。
延伸阅读 / 参考