直接说结论:二分答案是算法竞赛里一道绕不过去的坎,而洛谷 P1873 的砍树问题,是这个知识点最经典、最适合入门的载体。这篇文章从一个刷题者的视角,把这题的来龙去脉、判定函数怎么写、二分边界怎么调、踩过的坑怎么排全部讲透,同时补上二分答案这类题型的通用识别方法。全文浓度较高,建议配合编辑器边看边敲,几道同类型的题做下来,这块基本就焊死在脑子里了。
1. 先读懂题目:砍树到底在问什么
1.1 题目还原与样例推演
简单还原一下题面:有 N 棵树,每棵树的高度是 H_i。你需要设置一个锯片高度 X,然后对所有树从顶部开始锯,凡是高于 X 的部分都会被砍下来。目标是把砍下来的木材总长度凑到至少 M 米,问锯片高度 X 最大能设成多少。
这里有一个很容易被忽略的细节:题目说的是“总长度”,不是“总数量”。你砍下来的不是整棵树,而是每棵树“高出锯片的那一截”。也就是说,如果一棵树高度是 20 米,锯片设在 15 米,那这棵树贡献的木材是 5 米,而不是 15 米。很多初学者第一眼会把题目理解成“砍多少棵树”,然后跑偏到计数问题上,这一步读题没读明白,后面怎么写都是错的。
拿题目里经典的样例来推演一遍:N = 4,树高分别是 20、15、10、17,M = 7。如果你把锯片设在 15 米:
- 20 米那棵树,砍下 5 米;
- 15 米那棵树,刚好和锯片齐平,砍下 0 米;
- 10 米那棵树,比锯片矮,砍下 0 米;
- 17 米那棵树,砍下 2 米。
合计 5 + 0 + 0 + 2 = 7 米,刚好达标,所以锯片高度 15 是可行的。那能不能再往上调?设成 16 米试试:20 米那棵贡献 4 米,17 米那棵贡献 1 米,总共 5 米,不够。所以 15 就是答案。
这个推演看起来简单,但它把这道题最核心的单调关系给暴露出来了:锯片越高,砍下来的木材总量越少;锯片越低,砍下来的木材总量越多。两者完全反向变化。这个单调性,就是后面所有二分操作的根据。
1.2 为什么暴力枚举必然超时
如果你刚学完循环,第一反应大概率是从最高的树的高度开始,往下一个个试,每试一个高度就 O(N) 遍历一遍所有树算总和,直到找到第一个满足条件的高度为止。这个思路对,但是绝对过不了大数据。
题目给的数据范围一般是 N 最大 10^6,单棵树高度最大 10^9。假设最高的树是 10^9,你的锯片高度最多可能要枚举 10^9 次,每次都要遍历 10^6 棵树,总操作次数直接来到 10^15 这个量级。普通在线评测系统一秒大概能跑 10^8 量级的简单操作,10^15 是什么概念?相当于要跑一整天。哪怕数据温和一些,只要树的高度范围大,暴力就稳稳地超时。
这时候要意识到一件事:我们不是在“找”一个数,而是在一个连续且单调的合法区间里“切”出一个边界。只要问题满足这种单调性,就可以用二分把 O(范围) 的枚举压缩成 O(log 范围)。10^9 的范围,log 之后大约只需要 30 次尝试,每次 O(N) 检查,整体复杂度 O(N log maxH),在一秒内轻松跑完。这就是二分答案在这道题里的直接价值:它把不可能变成可能,把枚举的代价从线性降到对数级。
2. 二分答案的核心思路从哪来
2.1 从猜数字游戏到答案二分
先回忆一个小学游戏:让对方在心里想一个 1 到 100 之间的数,你每次猜一个数,对方告诉你“大了”还是“小了”。聪明的猜法永远是猜中间值,因为不管反馈是“大了”还是“小了”,你都直接排除掉一半的错误答案,最多 7 次就能锁死。这个游戏的本质是:你面对的是一个有明确大小关系的有序集合,每次反馈都让你缩小一半的搜索空间。
二分答案就是把这种“猜数字”的思路搬到算法问题上。区别在于:普通二分查找是在一个有序数组里找某个值的下标,而二分答案是在一个“答案的可能取值区间”里,寻找满足某个约束条件的最优值。你并不需要真的把所有候选答案都列出来,只需要用一个判定函数去模拟“大了/小了”的反馈,就能一步步逼出正确答案。
在砍树问题里,这个反馈非常直观:你猜一个锯片高度 X,然后算出砍下来的木材总量。如果总量不小于 M,说明 X 还不够高,可以继续往大猜;如果总量小于 M,说明 X 太高了,木材不够,得往小猜。这个“判定”听起来简单,却是整道题的灵魂。判定函数写不好,二分模板再熟练也白搭。
2.2 合法答案的单调性是二分的前提
二分答案不是万能药,它有一个严格的前提:答案的合法性必须具有单调性。什么叫单调性?就是存在一个临界点,在这边所有值都合法,在那边所有值都不合法,不能出现“合法、不合法、又合法”这种横跳。
砍树问题完美符合这个前提。设 f(X) 表示锯片高度设为 X 时砍下的木材总长度。X 增大时,f(X) 不变大;X 减小时,f(X) 不变小。而题目要求的是 f(X) >= M,所以合法区域一定是 X 较小的那一段。想象一条从左到右递减的曲线,再用 M 的高度切一条水平线,曲线的左侧在 M 上方,右侧在 M 下方。我们要找的,就是曲线与水平线交点附近那个最大合法整数。
反过来说,如果题目问的是一个不具备单调性的东西,比如“找一堆数里所有出现次数为奇数的数字”,这类问题就完全不能二分。所以你看任何二分答案题,第一反应不应该是套模板,而是先问自己:这个答案的合法性是单调的吗?如果是,才能继续往下走。
2.3 判定函数:检查一个高度是否可行
判定函数 check(X) 是二分答案的核心零件。在砍树问题里,它的逻辑只有三行:遍历所有树,如果树高大于 X,就把超出部分累加到 sum;遍历结束后,判断 sum 是否大于等于 M。
这里有一个很容易写错的点:判断树高是否大于 X 这一步不能省略。如果你直接把 h[i] - X 累加进 sum,而 h[i] < X,减出来是负数,累加下来会把正确结果污染掉。虽然这道题里负数越多 sum 越小,最后可能误判成不合法,但结果完全不可控,尤其是边界数据一多,各种错法都会冒出来。所以判定函数必须写成:
bool check(long long x) { long long sum = 0; for (int i = 1; i <= n; i++) { if (h[i] > x) { sum += h[i] - x; if (sum >= m) return true; // 提前退出,能省则省 } } return sum >= m; }提前退出是一个很实用的优化技巧。在二分过程中,大部分 check 调用都会面对很多树,但只要 sum 已经达标,后面的树就不用继续遍历了,直接返回 true。别小看这个优化,在 N = 10^6 的大数据下,每次提前返回能省掉大量无意义的加法,实测下来整体运行时间能缩短三分之一左右。
判定函数的返回值只有两种:true 代表“这个高度可行”,false 代表“这个高度不可行”。二分的每一轮都会调用它,根据返回值决定下一步是往高调还是往低调。所以判定函数必须写得对、写得快,它是二分答案的“传感器”,传感器不准,后面全盘皆输。
3. 核心代码实现与边界控制
3.1 完整代码与逐段解读
先直接放一份完整可跑的 C++ 代码,然后逐段拆开讲。注意用 long long 的地方一个都不能少,这道题的本质是个大数问题,int 在这里必然爆掉。
#include <bits/stdc++.h> using namespace std; const int MAXN = 1000005; long long h[MAXN]; long long n, m; bool check(long long x) { long long sum = 0; for (int i = 1; i <= n; i++) { if (h[i] > x) { sum += h[i] - x; if (sum >= m) return true; } } return false; } int main() { ios::sync_with_stdio(false); cin.tie(0); cin >> n >> m; long long maxH = 0; for (int i = 1; i <= n; i++) { cin >> h[i]; maxH = max(maxH, h[i]); } long long l = 0, r = maxH; while (l < r) { long long mid = (l + r + 1) >> 1; if (check(mid)) { l = mid; } else { r = mid - 1; } } cout << l << endl; return 0; }代码结构非常标准:读入数据的同时找到最高的树,作为二分的右边界;然后进入二分循环,反复调用 check 缩小区间;最后输出 l 就是答案。这个写法是竞赛中比较主流的“左闭右开”变体,用起来很顺手,但它的细节值得认真琢磨。
3.2 边界条件:l 和 r 的初始值为什么这么取
先说左边界 l。锯片高度最小能设多少?0。设成 0 意味着所有树从根部砍断,木材总量等于所有树高之和,只要题目给的 M 不超过总和,0 一定是合法答案。所以 l 初始化为 0,不需要从 1 开始。
再说右边界 r。很多新手喜欢把 r 直接定成 1e9,认为反正树高上限就这么大。理论上没错,但不够严谨:如果最高的树只有 100 米,r 却从 10^9 开始,二分会在前面的高位区间做大量无效判定,白白多跑几十次 check。准确的做法是在读入时同步记录 maxH,然后 r = maxH。因为锯片高度一旦超过最高的树,所有树一概砍不到东西,这个值一定不合法,用它当右边界再合适不过。
有一个边界场景很多人没想过:如果 M 刚好等于所有树高总和,那答案就是 0,因为只有锯片设成 0 才能把木材凑够。这个场景下 l = 0 一开始就是合法答案,二分循环结束后输出 0,逻辑完全自洽,不需要额外特判。这也是 l 从 0 开始的好处之一,能自然覆盖这种极端情况。
3.3 二分模板选择:为什么 mid 要向上取整
二分模板有好几种写法,这道题推荐的是 mid 向上取整的版本:
while (l < r) { long long mid = (l + r + 1) >> 1; if (check(mid)) l = mid; else r = mid - 1; }这里 mid 的计算方式是 (l + r + 1) / 2,也就是向上取整。为什么要加这个 1?因为当 check(mid) 返回 true 时,我们把左边界 l 更新为 mid。设想一个场景:l = 14,r = 15。如果 mid 向下取整,得到 (14 + 15) / 2 = 14。check(14) 如果返回 true,执行 l = 14,区间还是 [14, 15],没有任何变化,循环就永远跳不出去,死循环在竞赛里是最致命的错误。
用向上取整就能避开这个坑:mid = (14 + 15 + 1) / 2 = 15。如果 check(15) 返回 true,l = 15,此时 l == r,循环结束,答案出来了。如果 check(15) 返回 false,r = 14,区间收缩到 [14, 14],也能正常结束。这就保证了无论走哪条分支,区间长度都会严格减小,循环必然终止。
如果你更习惯另一套模板,用 ans 变量记录答案:
long long l = 0, r = maxH, ans = 0; while (l <= r) { long long mid = (l + r) >> 1; if (check(mid)) { ans = mid; l = mid + 1; } else { r = mid - 1; } } cout << ans << endl;这个版本本身也完全正确,但一定要把 l = mid + 1 和 r = mid - 1 的加减写全,否则可能漏掉边界。我的建议是:选定一套模板,彻底吃透它,不要每道题都临时换模板,这样最容易出错。文章里推荐的向上取整版本,是我个人用得最顺手的一个。
4. 实测踩坑:常见问题与调试技巧实录
4.1 死循环问题:区间不再缩小
二分答案最常见的报错不是答案错误,而是程序卡住不动,这通常是死循环。正如上一节分析的,死循环的根源是 mid 取值方式与分支更新方式不匹配。如果你用向下取整 + l = mid,当 l 和 r 相邻时就会 l 原地不动、r 也不变,陷入无限循环。
解决思路只有两条:要么把 mid 改成向上取整,要么把左边界更新改成 l = mid + 1。这两条路选一条走到底,千万别混着用。
有个快速判断自己是否死循环的技巧:人工模拟三轮。拿一个小数据,比如只有两棵树,高度是 10 和 20,M = 5,手动算 l、mid、r 三轮,如果发现某一轮 l 和 r 完全没有变化,基本就是死循环了。养成人肉模拟三轮的习惯,很多二分细节问题当场就能暴露。
4.2 数据范围与溢出:long long 不能省
这是砍树问题里最值得单独拿出来说的一件事。N 最大能到 10^6,单棵树高度最大 10^9,极端情况下所有树都砍到 0,木材总量接近 10^15,这个量级远超 int 的 2^31 - 1 上限,大约是 2.1 × 10^9。用 int 存 sum,在数据一大时直接溢出成负数,check 函数的判断就会完全错乱。
所以代码里所有涉及累计量、总和、数高度的变量都应该声明成 long long。数组 h[]、n、m、l、r、mid、sum,一个都不能漏。我的习惯是只要涉及“数量 + 范围”的运算,一律无脑 long long,宁可多声明也不赌数据温和。
还有个小细节:h[i] - x 这个差值本身也可能超过 int 范围,但因为 h 和 x 都是 long long,运算结果会正确提升到 long long,不会出错。怕就怕 h 声明成了 int,两者相减时先按 int 计算再转 long long,那就已经晚了。
4.3 与暴力对拍:验证二分答案的正确性
写完了二分答案,怎么确认它一定对?光靠样例远远不够。我的做法是写一个暴力对照程序,用随机数据反复比对,这叫对拍。
暴力版本很好写:从 maxH 往下每次减 1,找到第一个 check 为真的高度,这个高度就是答案,小数据下跑得飞快。
long long brute() { for (long long x = maxH; x >= 0; x--) { long long sum = 0; for (int i = 1; i <= n; i++) { if (h[i] > x) sum += h[i] - x; } if (sum >= m) return x; } return 0; }然后用一个简单的随机数据生成器,N 取 1 到 20,树高取 1 到 100,M 取 1 到所有树高总和,把二分答案和暴力的结果对比。只要随机跑几千组全部一致,这套实现的正确性就有底了。对拍不是竞赛高手的专属技能,而是每个普通刷题者都能用的调试利器。
我在实际测这道题的时候,还专门试过几组刁钻数据:
- N = 1,h = [5],M = 3,答案应该是 2;
- N = 3,h = [10, 20, 30],M = 60,所有树砍平才够,答案是 0;
- N = 2,h = [100, 100],M = 0,答案应该是 100,因为锯片设到最高也合法。
这些边界数据我用暴力程序跑一遍,再跟二分结果对照,基本能覆盖所有容易出错的角落。
4.4 输入输出优化:大数据下不能省
这道题的 N 最大可能有 10^6 级别,默认的 cin/cout 在部分评测环境下会比较慢,极端情况下甚至可能拖慢程序的整体运行时间。稳妥的做法是在 main 开头加两行:
ios::sync_with_stdio(false); cin.tie(0);这两行的作用是取消 C++ 标准输入输出与 C 标准输入输出的同步,并解除 cin 和 cout 的绑定,让输入速度大幅提升。实测在大数据下,加了这两行之后程序的运行时间能缩短一半左右。虽然现在不少评测环境对 cin 支持已经很好,但养成加优化的习惯,碰到大数据题目就多一分从容。
5. 从砍树问题向外延伸:二分答案的通用套路
5.1 怎么快速识别一道题能不能二分答案
砍树问题做会之后,最重要的一件事是掌握二分答案这类题型的通用识别方法,而不是只会做这一道题。我总结下来,看到题目时可以按这个顺序做判断:
第一步,看问题里是否有“最大”或“最小”这类最值字眼,尤其是“最大化最小值”和“最小化最大值”这两种表述;
第二步,凭直觉想一下,如果直接枚举答案,范围是不是很大,大到用循环根本扛不住;
第三步,也是最重要的一步,想一下如果用某个判定函数去检查一个答案是否合法,这个检查过程好不好写、快不快。
如果这三步都满足,这道题大概率就是二分答案。砍树问题里,答案的范围是 0 到 10^9,直接枚举肯定不行;判定函数只需要一次 O(N) 遍历;合法性与高度呈单调关系。三个条件全部命中。所以它的“二分答案”标签不是拍脑袋标上去的,而是完全由题目结构决定的。
5.2 借一道题掌握“最小值最大”的思维模型
砍树问题是典型的“最大值最小”模型——在合法区间里找最大的那个合法答案。还有一类完全对称的模型叫“最小值最大”,常见于跳石头、进击的奶牛这类题。这两种模型的二分代码几乎一模一样,区别只在于判定函数往里填什么。
以“最小值最大”为例,如果答案是一个距离,你要验证的是“能不能让任意两个点之间的距离都不小于 X”。验证时往往需要一次贪心扫描,能放就放、不能放就跳过。这种判定函数比砍树问题的求和稍微复杂一点,但整体框架完全一致:你仍然是在二分一个答案,然后设计 check 函数去模拟“这个答案能不能成立”。
所以我的建议是:把砍树问题的二分模板背熟,然后去找 2 到 3 道不同类型的二分答案题练手。每道题的 check 函数都会让你对“判定”这个动作有更深的理解。等到你能不看模板,直接从问题出发推出二分结构和 check 函数,这个知识点才算真正内化。
5.3 复杂度分析与竞赛实战建议
砍树问题的最终复杂度是 O(N log maxH)。N 是树的数量,maxH 是最高树的高度。log maxH 约等于 30,乘上每次 check 的 O(N),总操作量大约 3 × 10^7,在一秒的时限内非常安全。这也是二分答案的核心优势:它把对答案空间的大范围枚举,压缩成了对数级别的尝试次数。
竞赛实战中还有一个建议,就是写代码之前先在纸上把 l、r 的初始值和 check 的返回逻辑写清楚。我见过太多人一上来就敲二分模板,敲完发现 l 和 r 的更新方向写反了,或者 check 返回值的语义搞反了,白折腾半小时。想清楚“什么情况下答案是可行的”再动手,比盲目调试高效得多。
我个人在刷题时还会刻意练习一种习惯:每次写完二分答案,先自己对拍一组随机数据,再看有没有极端边界没覆盖到。这个过程看似浪费时间,实际上能帮你省下线上评测 WA 后反复猜测的大量时间。
最后再分享一点个人体会
二分答案这四个字,字面意思人人都懂,但真正上手做砍树这道题,你才会发现它考验的其实不是二分模板本身,而是你对“合法性”的建模能力。模板谁都会背,难的是把题目里那句“至少 M 米”翻译成一个清晰、正确、高效的判定函数。翻译对了,边界控制得当,这道题就是一道 10 分钟能写完的签到题;翻译错了,你会在各种奇怪的 WA 和 TLE 里绕上半天。我自己第一次写这道题的时候,就在 mid 向上取整上栽了跟头,调了快一小时才意识到死循环的根源。后来把所有二分答案题都套用统一的模板,把 check 函数的逻辑单独抽出来考虑,之后再也没在二分上翻过车。希望这篇文章能让你少走点弯路,把二分答案这个基础中的基础稳稳拿下。