二分答案的套路非常反直觉,但一旦学会,你会在无数题里认出它:
题目问「最大值最小是多少」「最小值最大是多少」「最多能选几个」—— 十有八九是二分答案。
它的思路是:不直接求答案,而是猜一个答案,然后写个函数验证这个猜测行不行。
听起来像作弊,但它是完全严谨的。而且「验证」通常比「求解」简单一百倍 —— 这一章的验证函数只有 8 行,而直接求解要写动态规划。
第 8 章那个二分模板,这一章原封不动地用。变的只有 check。
1一句话问题
给 n 个正整数排成一列,要把它切成不超过 m 段连续的子段。
每段有一个和,m 段里有一个最大的和。
问:怎么切,才能让「最大的那段和」尽量小?
输入
6 3 7 2 5 10 8 3
输出
14
第一行是 n 和 m,第二行是那 n 个数。
切法是 [7 2 5] [10] [8 3],三段的和分别是 14、10、11,最大的那个是 14;
再也找不到更小的了。⚠ 程序只输出这个最小的最大值,不输出切法。
「把 n 本书分给 m 个抄书员,每人抄连续的一段,问最累的那个人最少要抄多少页」—— 这就是洛谷 P1281。同一道题换了个皮。
「最大值最小化」的本质永远是:有一批任务要分配,想让最重的那个负担最轻。
2先用纸笔手算一遍
7 2 5 10 8 3 切成 3 段,穷举几种切法:
[7] [2 5 10] [8 3] → 7, 17, 11 最大 17
[7 2] [5 10] [8 3] → 9, 15, 11 最大 15
[7 2 5] [10] [8 3] → 14, 10, 11 最大 14 ← 目前最好
[7 2 5] [10 8] [3] → 14, 18, 3 最大 18
[7 2 5 10] [8] [3] → 24, 8, 3 最大 24
手算完你会有个感觉:「怎么切」这件事很难直接想清楚。 切法太多了(n 个位置里挑 m-1 个切点),而且好坏没有明显规律。
不要问「怎么切最好」,改成问:
「每段和都不超过 14」这个要求,能做到吗?
这个问题好回答得多:从左往右能装就装,装不下就另起一段,数一数用了几段。
上限 14: 7+2+5=14 装满了 | 10 | 8+3=11 用了 3 段 ≤ 3 → 能做到 ✓
上限 13: 7+2=9 | 5+... 装不下 10 | 10 | 8+3 用了 4 段 > 3 → 做不到 ✗于是「求最优解」变成了「验证一个猜测」。而答案, 就是所有能做到的上限里最小的那个。
3验证函数:8 行贪心
// 每段和都不超过 limit,能不能只用 m 段装完?
bool ok(long long limit) {
int seg = 1; long long cur = 0;
for (int i = 1; i <= n; i++) {
if (a[i] > limit) return false; // 单个数就超了,怎么切都不行
if (cur + a[i] <= limit) cur += a[i]; // 塞进当前这段
else { seg++; cur = a[i]; } // 装不下,另起一段
}
return seg <= m;
}
「能装就装」不会吃亏:当前这段多装一个数,剩下要装的东西就少一个, 后面只可能更轻松,绝不可能因此多用一段。
反过来,如果故意少装一个留到下一段,剩下的东西反而变多了,段数只会更多。
这个「多装绝不吃亏」的论证,就是贪心的正确性证明。 阶段 4(第 19、20 章)会专门讲怎么做这种论证 —— 现在先记住: 贪心不是「感觉对就行」,是要能说出为什么。
4暴力:从小到大挨个试
有了 ok(),最笨的办法就是从最小的可能答案开始,一个一个往上试:
// 数列分段:最大值最小化 —— 暴力:把答案从小到大挨个试//// 输入:第一行 n m(n 个数,要分成 m 段)// 第二行 n 个正整数// 输出:一个整数 —— 在所有「把数列切成 m 段连续子段」的方案里,// 「每段和的最大值」最小能是多少//// 例:4 3 / 1 5 2 4 → 答案 6// 切成 [1 5] [2] [4],三段的和分别是 6、2、4,最大值 6。// 再也找不到更小的了。//// ============ 这一章的核心动作 ============//// 「怎么切才最优」很难直接想。但换一个问法,事情立刻简单了://// **给定一个上限 x,能不能把数列切成不超过 m 段、且每段和都 <= x?**//// 这个问题好回答:从左往右贪心地装,装不下就开新的一段,数一数用了几段。// 用的段数 <= m → x 可行// 用的段数 > m → x 太小了//// 于是「求答案」变成了「验证一个猜测」。而验证比求解简单一百倍。//// 这份暴力就是:从最小的可能答案开始,一个一个往上试,第一个能通过验证的就是答案。// 复杂度 O(答案范围 × n) —— 答案范围可以到所有数之和,所以它很慢。// 正解只改一件事:把「一个一个试」换成「二分着试」。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<long long> a;
/** * 能不能让每段和都不超过 limit,并且段数不超过 m ? * * 贪心:从左往右能装就装,装不下就另起一段。 * 为什么贪心是对的:当前这段能多装一个就多装一个,绝不会让后面变难 —— * 少装一个只会让后面剩下更多东西,段数只可能更多,不可能更少。 */bool ok(long long limit) { int seg = 1; // 至少一段 long long cur = 0; for (int i = 1; i <= n; i++) { if (a[i] > limit) return false; // 单个数就超了,怎么切都不行 if (cur + a[i] <= limit) { cur += a[i]; // 塞进当前这段 } else { seg++; // 装不下,另起一段 cur = a[i]; } } return seg <= m;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; a.assign(n + 1, 0);
long long sum = 0, mx = 0; for (int i = 1; i <= n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); }
// 答案不可能小于「最大的那个数」(它总得待在某一段里), // 也不可能大于「所有数之和」(那是只分一段的情况)。 for (long long x = mx; ; x++) { if (ok(x)) { cout << x << "\n"; break; } if (x > sum) { cout << sum << "\n"; break; } // 兜底,正常走不到这里 } return 0;}点「运行 ▶」看结果
答案不可能小于「最大的那个数」(它总得待在某段里),
也不可能大于「所有数之和」(那是只分一段)。所以从 max 试到 sum。
这份代码是完全正确的。它慢,但慢得很诚实 —— 待会儿它就是对拍的标准答案之一。
5实测:它有多慢
本机实测(数值 1~100000,段数 n/10):
| n | 逐个试 | 二分着试 |
|---|---|---|
| 5 000 | 2.60 秒 | 0.003 秒 |
| 10 000 | 5.30 秒 | 0.004 秒 |
| 20 000 | 10.6 秒 | 0.005 秒 |
逐个试的复杂度是 O(答案范围 × n)。
「答案范围」由数值大小决定,不是由 n 决定。 数值都是个位数的话,答案范围只有几十,暴力一下就试完了; 数值上到十万,答案范围就是几十万。
所以 genBig.cpp 里特意把数值造到 100000。
造数据要打在暴力的痛点上 —— 这句话这本教材里已经说第三遍了,
因为它是真的重要。
6★ 关键的一步:可行性是单调的
把每个候选答案的可行性列出来,你会看到这个形状:
上限: 10 11 12 13 14 15 16 17 ... 35
可行: ✗ ✗ ✗ ✗ ✓ ✓ ✓ ✓ ... ✓
↑
我们要的答案✗ 全在左边,✓ 全在右边,中间只有一条分界线。
为什么一定是这个形状?因为上限放宽了只会更容易: 如果上限 14 能切成 3 段,那上限 15 用同样的切法当然也行。 所以「可行」这个性质一旦成立,就对更大的值全部成立。
这正是第 8 章说的二分的真正条件:不是「有序」,是「存在分界点」。
于是第 8 章那个模板原封不动搬过来,只把 check 换成 ok:
long long l = 最大的那个数, r = 所有数之和;
while (l < r) {
long long mid = l + (r - l) / 2;
if (ok(mid)) r = mid; // 可行 → 答案不会更大
else l = mid + 1; // 不可行 → 答案只能更大
}
// l 就是答案O(n log sum)。sum 就算是 10¹⁴,log 也只有 47 —— 验证 47 次就完事了。
判一个边界安不安全,只有一条:下界 ≤ 答案,上界 ≥ 答案。
和「松」「紧」没有关系。
- 上界写成
sum?稳(M = 1时答案就是它)。写成别的「感觉够大」的值?出事了都不知道为什么。 - 下界写成
0?可以。⚠ 写成1就会 WA —— 题面是「非负整数」, 数列全是0时答案就是0,而1把它挡在范围外面了。 - 下界写成
sum / m(看着挺聪明)?也安全,而且有个一句话的理由: 分成m段,最大的那段不可能小于平均值(鸽巢原理)⇒ 答案≥ sum/m恒成立。
拿不准就把范围放宽 —— 而且代价比你以为的还小。
实测这道题:下界取 max(Aᵢ)、sum/m、1 三种写法,二分都是 42 轮,一轮不差
(上界 sum ≈ 5×10¹² 比三个下界大四五个数量级,log2 的差别根本看不见)。
⇒ 别在下界紧不紧上花时间,只检查包没包住。
★ 这三条都是习题解析 P1182 里一版一版跑出来的 ——
⚠ 其中两条推翻了这一段原来的说法(原来写着「下界写成 1 可以」「sum/m 危险」,
两句都不对)。那一页还有一件更有意思的事:ok() 里少写一句判断,
单独用一次都不会错,可它和「下界写成 1」凑在一起就 WA 几百次。
7正解
// 数列分段:最大值最小化 —— 二分答案//// 输入输出和 brute.cpp 完全一样。// **`ok()` 函数一个字都没改**,改的只有「怎么找那个答案」。//// 为什么能二分?因为可行性是**单调**的://// x 太小 → 不可行 x 够大 → 可行// ✗ ✗ ✗ ✗ ✗ ✗ | ✓ ✓ ✓ ✓ ✓ ✓ ✓// ↑// 我们要的就是这条分界线上的第一个 ✓//// 如果 x 可行(能切成 ≤ m 段),那 x+1 更宽松,一定也可行。// 所以整条数轴一定是「一段 ✗ 接一段 ✓」,中间只有一个分界点。//// 这正是第 8 章说的二分的真正条件:**不是「有序」,是「存在分界点」。**// 于是第 8 章那个模板原封不动搬过来,只把 check 换成 ok()://// l = 最小可能答案, r = 最大可能答案// while (l < r) { mid = l + (r-l)/2; if (ok(mid)) r = mid; else l = mid + 1; }//// 复杂度 O(n log sum)。sum 就算是 10^14,log 也只有 47。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<long long> a;
bool ok(long long limit) { int seg = 1; long long cur = 0; for (int i = 1; i <= n; i++) { if (a[i] > limit) return false; if (cur + a[i] <= limit) cur += a[i]; else { seg++; cur = a[i]; } } return seg <= m;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; a.assign(n + 1, 0);
long long sum = 0, mx = 0; for (int i = 1; i <= n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); }
// 二分的范围要「一定包含答案」: // 下界 mx —— 最大的那个数总得待在某一段里,所以答案不可能比它小 // 上界 sum —— 只分一段时就是全部之和,答案不可能比它大 long long l = mx, r = sum; while (l < r) { long long mid = l + (r - l) / 2; if (ok(mid)) r = mid; // mid 可行 → 答案在 mid 或更小 else l = mid + 1; // mid 不可行 → 答案更大 }
cout << l << "\n"; return 0;}点「运行 ▶」看结果
把它和 brute.cpp 并排看:ok() 函数一个字都没改。
变的只有「怎么找那个答案」—— 从「一个一个试」变成「二分着试」。
// 二分答案 —— 把「猜一个 → 验证」的全过程打印出来//// 输入:n m / n 个正整数(用小数据)// 输出:先把每一个候选答案的可行性列成一张表(✗✗✗✓✓✓),// 再演示二分是怎么在这张表上找到那条分界线的//// 那张表是这一章最值得看的东西:// **它一定是「左边一片 ✗、右边一片 ✓」,中间只有一个分界点。**// 有了这个形状,二分才成立。//// 每次做二分答案的题,都应该先在脑子里(或者纸上)确认这个形状。// 确认不了,说明可行性不单调,那就不能二分。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<long long> a;
bool ok(long long limit, bool verbose = false) { int seg = 1; long long cur = 0; string detail = "["; for (int i = 1; i <= n; i++) { if (a[i] > limit) { if (verbose) cout << " 单个数 " << a[i] << " 就超过上限了,直接不行\n"; return false; } if (cur + a[i] <= limit) { cur += a[i]; detail += to_string(a[i]) + " "; } else { seg++; cur = a[i]; detail += "| " + to_string(a[i]) + " "; } } detail += "]"; if (verbose) { cout << " 贪心地装:" << detail << " 用了 " << seg << " 段," << (seg <= m ? "不超过 " : "超过了 ") << m << " → " << (seg <= m ? "可行 ✓" : "不可行 ✗") << "\n"; } return seg <= m;}
int main() { if (!(cin >> n >> m)) return 0; if (n <= 0 || n > 12) { cout << "这份是用来看过程的,请用 1 <= n <= 12\n"; return 0; }
a.assign(n + 1, 0); long long sum = 0, mx = 0; for (int i = 1; i <= n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); }
cout << "数列:"; for (int i = 1; i <= n; i++) cout << a[i] << " "; cout << " 要分成不超过 " << m << " 段\n"; cout << "答案一定在 [" << mx << ", " << sum << "] 里(下界 = 最大的那个数,上界 = 全部之和)\n\n";
cout << "把每个候选答案的可行性列出来:\n "; long long firstOk = -1; for (long long x = mx; x <= sum; x++) { bool r = ok(x); if (r && firstOk < 0) firstOk = x; cout << x << (r ? "✓ " : "✗ "); } cout << "\n\n 看这一行的形状:左边一片 ✗,右边一片 ✓,中间只有一个分界点 —— 就是 " << firstOk << "。\n 「有这个形状,才能二分」。\n\n";
cout << "二分是这样找到它的:\n"; long long l = mx, r = sum; int step = 0; while (l < r) { long long mid = l + (r - l) / 2; cout << " 第 " << ++step << " 轮:候选范围 [" << l << ", " << r << "],猜 mid = " << mid << "\n"; bool good = ok(mid, true); if (good) { cout << " → 可行,答案不会更大,r = mid = " << mid << "\n"; r = mid; } else { cout << " → 不可行,答案只能更大,l = mid + 1 = " << (mid + 1) << "\n"; l = mid + 1; } }
cout << "\n答案 = " << l << " (只用了 " << step << " 轮验证,而逐个试要试 " << (firstOk - mx + 1) << " 次)\n"; return 0;}点「运行 ▶」看结果
8单步看「猜 → 验证 → 缩小范围」
- 上面那条数轴是所有候选答案。每验证一个就盖上 ✓ 或 ✗。
- 下面画的是当前猜测下贪心切出来的分段,以及用了几段。
- 播完之后回头看数轴:红的全在左、绿的全在右。这个形状就是二分的许可证。
试试这两个极端:
- 把
m改成1—— 只能分一段,数轴上只有最右边那一格是 ✓。 - 把
m改成10(比数列还长)—— 几乎整条数轴都是 ✓,答案就是最大的那个数。
9★ 对拍:两种,都要做
把「二分」那一栏换成你自己默写的,再点开始。
标准答案是「逐个试」的暴力 —— 它和你的二分共用同一个 ok(),
所以这一轮对拍验的是二分的边界写对没有。
// 数列分段:最大值最小化 —— 二分答案//// 输入输出和 brute.cpp 完全一样。// **`ok()` 函数一个字都没改**,改的只有「怎么找那个答案」。//// 为什么能二分?因为可行性是**单调**的://// x 太小 → 不可行 x 够大 → 可行// ✗ ✗ ✗ ✗ ✗ ✗ | ✓ ✓ ✓ ✓ ✓ ✓ ✓// ↑// 我们要的就是这条分界线上的第一个 ✓//// 如果 x 可行(能切成 ≤ m 段),那 x+1 更宽松,一定也可行。// 所以整条数轴一定是「一段 ✗ 接一段 ✓」,中间只有一个分界点。//// 这正是第 8 章说的二分的真正条件:**不是「有序」,是「存在分界点」。**// 于是第 8 章那个模板原封不动搬过来,只把 check 换成 ok()://// l = 最小可能答案, r = 最大可能答案// while (l < r) { mid = l + (r-l)/2; if (ok(mid)) r = mid; else l = mid + 1; }//// 复杂度 O(n log sum)。sum 就算是 10^14,log 也只有 47。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<long long> a;
bool ok(long long limit) { int seg = 1; long long cur = 0; for (int i = 1; i <= n; i++) { if (a[i] > limit) return false; if (cur + a[i] <= limit) cur += a[i]; else { seg++; cur = a[i]; } } return seg <= m;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; a.assign(n + 1, 0);
long long sum = 0, mx = 0; for (int i = 1; i <= n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); }
// 二分的范围要「一定包含答案」: // 下界 mx —— 最大的那个数总得待在某一段里,所以答案不可能比它小 // 上界 sum —— 只分一段时就是全部之和,答案不可能比它大 long long l = mx, r = sum; while (l < r) { long long mid = l + (r - l) / 2; if (ok(mid)) r = mid; // mid 可行 → 答案在 mid 或更小 else l = mid + 1; // mid 不可行 → 答案更大 }
cout << l << "\n"; return 0;}brute.cpp 和 fast.cpp 共用同一个 ok() 函数。
也就是说,如果那个贪心 ok() 本身就是错的,两份程序会一起错 ——
输出当然一致,对拍全过,你还以为自己写对了。
这是对拍最容易被忽略的陷阱:两个错得一模一样的程序,是对不出问题的。
所以我们再写一份 动态规划版本 —— 它不依赖任何贪心,
把所有切法都考虑了一遍(f[i][j] = 前 i 个数分成 j 段的最优解)。
慢是慢(O(n²m)),但它和贪心思路完全不同,能真正验证 ok() 是对的。
// 数列分段:最大值最小化 —— 二分答案//// 输入输出和 brute.cpp 完全一样。// **`ok()` 函数一个字都没改**,改的只有「怎么找那个答案」。//// 为什么能二分?因为可行性是**单调**的://// x 太小 → 不可行 x 够大 → 可行// ✗ ✗ ✗ ✗ ✗ ✗ | ✓ ✓ ✓ ✓ ✓ ✓ ✓// ↑// 我们要的就是这条分界线上的第一个 ✓//// 如果 x 可行(能切成 ≤ m 段),那 x+1 更宽松,一定也可行。// 所以整条数轴一定是「一段 ✗ 接一段 ✓」,中间只有一个分界点。//// 这正是第 8 章说的二分的真正条件:**不是「有序」,是「存在分界点」。**// 于是第 8 章那个模板原封不动搬过来,只把 check 换成 ok()://// l = 最小可能答案, r = 最大可能答案// while (l < r) { mid = l + (r-l)/2; if (ok(mid)) r = mid; else l = mid + 1; }//// 复杂度 O(n log sum)。sum 就算是 10^14,log 也只有 47。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<long long> a;
bool ok(long long limit) { int seg = 1; long long cur = 0; for (int i = 1; i <= n; i++) { if (a[i] > limit) return false; if (cur + a[i] <= limit) cur += a[i]; else { seg++; cur = a[i]; } } return seg <= m;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; a.assign(n + 1, 0);
long long sum = 0, mx = 0; for (int i = 1; i <= n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); }
// 二分的范围要「一定包含答案」: // 下界 mx —— 最大的那个数总得待在某一段里,所以答案不可能比它小 // 上界 sum —— 只分一段时就是全部之和,答案不可能比它大 long long l = mx, r = sum; while (l < r) { long long mid = l + (r - l) / 2; if (ok(mid)) r = mid; // mid 可行 → 答案在 mid 或更小 else l = mid + 1; // mid 不可行 → 答案更大 }
cout << l << "\n"; return 0;}对拍的标准答案,最好是用完全不同的思路写出来的。
同一个思路写两遍,只能验出打字错误; 不同思路写两遍,才能验出想法错误。
这条经验会跟着你一辈子 —— 它不只适用于算法竞赛。
10套路总结:怎么认出「这题要二分答案」
- 题目问的是「最大值最小」「最小值最大」「最多/最少能怎样」。
- 直接求最优解很难,但「给定一个值,判断能不能达到」很容易。
- 这个判断是单调的:值放宽了就更容易满足(或反过来)。
三条都对上,就套模板:
l = 答案下界, r = 答案上界;
while (l < r) {
mid = l + (r - l) / 2;
if (check(mid)) r = mid; // 求「最小的可行值」
else l = mid + 1;
}求「最大的可行值」怎么办? 最省事的办法是把问题反过来定义 check,仍然用这一个模板 —— 不要去记第二套「上取整 + l = mid」的写法,那正是第 8 章说的混搭翻车现场。
| 题目怎么说 | check 是什么 |
|---|---|
| 分成 m 段,最大段和最小 | 贪心分段,数段数(本章) |
| 砍树取够 M 米,锯片最高多少 | 按高度砍一遍,累加木材量 |
| 移走 M 块石头,最小跳跃距离最大 | 贪心移石头,数移了几块 |
| n 头牛放进畜栏,最小间距最大 | 贪心放牛,数放了几头 |
| 第 k 小的数是多少 | 数一数「不超过 x 的有几个」 |
全都是同一个模板 + 一个不同的 check。check 才是每道题的真正内容。
11自测
- 洛谷 P1182 数列分段 Section II解析 → —— 本章原题。必须一次写对
- 洛谷 P1873 砍树解析 → —— 入门二分答案。check 是「按这个高度砍,能得到多少木材」
- 洛谷 P2678 跳石头解析 → —— NOIP2015。最小值最大化,check 是贪心地数「要移走几块石头」
- 洛谷 P1024 一元三次方程求解解析 → —— NOIP2001。实数二分,终止条件要用精度(while (r - l > 1e-6))而不是 l < r
五章走完,你手上多了五个零件:枚举与模拟、前缀和与差分、 双指针、二分查找、二分答案。
它们会作为骨架出现在后面几乎每一道题里。
下一站是阶段 2(排序与分治)。第 10 章会从冒泡讲到归并再到快排 —— 而归并排序正是第 11 章「分治」的载体,也是第 2 章「大问题 = 小问题 + 一步真活 + 小问题」 最标准的一次现身。