很多刚接触算法竞赛的朋友一听到“二分查找”这四个字,脑子里浮现的往往是“在一个有序数组里找一个数”的模板题。但真上了考场,二分查找出场的方式远比这个丰富得多,尤其是当它化身为“二分答案”的时候,整道题的难度和思维量会瞬间上一个台阶。P8088 『JROI-5』Autumn 这道普及+的题,就是非常典型的一例:代码量不大,适合二分查找算法的核心技巧,但第一眼看上去就像一道纯模拟题,稍不注意就会在“模拟每一天”的思路上越走越远,最后在超时边缘痛苦挣扎。
这篇文章我会把这题从读题、建模、二分答案的设计、边界处理到最终的AC代码,一条龙拆开讲清楚。同时把我在实际做题过程中踩过的坑、总结的模板、以及调试二分问题的通用套路全部放出来,不管你是刚开始刷普及组题的新手,还是已经准备进阶提高组的朋友,应该都能从中拿到一些能直接用的东西。
1. 题目拿到手,先别急着敲代码
1.1 P8088 到底考什么:从“线性扫”到“二分跳”
先说结论:这道题的核心考点就是二分查找,更准确地说,是二分答案。题目背景我印象里是围绕一片果园或者农庄展开的,大概意思是给出若干棵树或者若干组生产数据,每棵树/每组设备每天产生的资源量各不相同,有些还有生产周期、隔天产出的设定,现在给定一个目标值M,问你最早在哪一天,累计产量能达到或者超过这个M。
题目本身读起来非常像一个“日子一天一天过、产量一天一天加”的模拟题。如果n特别小、天数上限也不大,那暴力循环确实能做。但普及+的题不会给你这种甜头,数据范围里天数上限通常给到 10^15 甚至更高,n也在 10^5 级别。你如果老老实实从第1天模拟到第10^15天,哪怕内部只用O(n)遍历一次,那也是 10^20 操作量级,跑一千年都跑不完。
所以这道题真正的第一个思维坎,是你能不能意识到:我们需要找的不是“模拟到哪一天”,而是“答案在哪一天”。天数和累计产量之间是严格单调递增的关系——天数越大,累计产量只会越高(或者持平,但不会下降)。这种单调性一出现,二分查找就呼之欲出了:在1到上限之间猜一个天数mid,用O(n)的方式判断“第mid天能不能达标”,然后根据判断结果缩小答案范围。
从“线性扫”到“二分跳”,这不是一个简单的实现替换,而是一种思维模式的转变:你不再是让时间流动,而是主动跳到某一个时间点上去观察状态。这个观察行为本身只需要O(n)的成本,但因为你用了二分,把观察次数压缩到了 log 级别,整个复杂度就从 O(n * T) 降到了 O(n * log T)。这一降,直接决定你能不能拿满分。
1.2 难度“普及+”意味着什么:看起来是暴力,其实是思维题
很多人看到难度标签是“普及+”,会下意识觉得这题应该是一个“模板题+一点点小变形”。但我个人的看法是,“普及+”这个档位恰恰是区分“背过模板”和“真会做题”的分水岭。
模板题是什么?给你一个有序数组和一个target,叫你写二分查找,那叫背板子,只要会调库或者能默写三种模板之一,分就到手了。而 Autum 这种题,它不会在标题里写“请用二分查找”,它藏在题面里的是一堆业务条件:每天产多少、隔几天集中消耗一次、仓库容量有上限、产量达标之后才计入统计……你需要在读题之后自己看出来“这个问题其实是在一个单调函数上做搜索”,并且自己亲手把check函数写对。
我当时第一次看这道题,脑子里第一个念头也是模拟。毕竟数据量看着不大,n才1e5,结果一看到天数上限,瞬间清醒。然后我就开始在草稿纸上画时间轴:怎么把一棵树的产量用函数表示出来,怎么把多棵树的产量合并成“第mid天的总产量”。等我把这些数学表达式写清楚,二分答案的第一个判断条件已经满足了:存在一个单调的评估函数f(x),并且f(x)的真假可以快速计算。
这里也顺便说一句,很多新手在做二分题目的时候,喜欢直接套模板,然后把注意力全部放在mid怎么取、while怎么写上面。但真正决定这道题能不能AC的,永远是你有没有把题目条件成功翻译成一个“单调布尔函数”。翻译对了,模板怎么选都行;翻译错了,模板再标准也是白搭。
1.3 从关键词看题目:二分查找在竞赛题里的三种出场姿势
聊到“二分查找”这个关键词,大家在网上搜到的资料,尤其是C语言版本、PTA函数的那些练习,绝大多数讲的是最基础的“在有序数组里二分找一个数”。但竞赛中的二分查找,至少有三种完全不同的形态,你如果不把它们区分清楚,看到P8088这种题就容易懵。
第一种是最常见的二分查找,就是在一个排序好的数组里面找一个值或者找边界,典型工具是C++里的 lower_bound / upper_bound,以及自己手写的标准二分。第二种是二分答案,也就是Autumn这道题用到的方式:答案不是一个数组中现成的元素,而是某个整数范围内的一个值,你通过不断判断“当前值是否满足条件”来逼近真实答案。第三种则是更进阶的二分套二分,通常用于带权逆序对、二维数点等题目里,外面二分答案,里面用树状数组或线段树查数量。
对于做普及+题目阶段的选手来说,第二种形态是性价比最高、也最需要练熟的。因为很多所谓的“二分查找题”,本质上都是二分答案题,它们不会给你现成的有序数组,而是给你一个隐形的、单调的函数关系。你能不能在脑子里补出这个函数,才是真正的考点。
2. 二分答案的核心套路:从单调性到check函数
2.1 单调性为什么是二分的前提:搞清楚你搜索的到底是什么
如果让我一句话说清楚二分答案和普通二分查找的区别,我会说:普通二分是在数组上找元素,二分答案是在函数的定义域上找边界。而函数能在定义域上二分,靠的就是单调性。
什么叫单调性?就是随着x变大,check(x)返回的结果只会从“假”变成“真”,并且一旦变成真之后,后面就永远是“真”。这是二分答案能成立的大前提。比如Autumn这题,如果第mid天累计产量达到M了,那么第mid+1天必然也达到M,因为产量只会累计增加,不会凭空减少。反过来,如果第mid天没达到,那mid之前的天也一定达不到。这样整个天数区间就被分为两段:左边一段全是“不达标”,右边一段全是“达标”,我们要找的,就是这两段的分界点。
这个思想可以类比成在一个很长的走廊里找灯亮起的位置:走廊前一半是黑的,后一半是亮的,你每次站在中间看一眼灯亮没亮,就能把搜索范围砍掉一半。但这里的前提是——走廊必须真的是一半黑一半亮,如果灯一会儿亮一会儿灭,你站在中间看一眼根本判断不了该往左走还是往右走,二分就完全失效了。所以每次做二分答案题,我建议你做的第一件事不是写代码,而是在草稿纸上确认三句话:第一,我猜的这个变量是什么;第二,我用来判断好坏的函数是什么;第三,这个函数是不是单调的。三句话都成立,再开始写不迟。
我还想多说一句,单调性并不要求“严格递增”。比如“第mid天累计产量 >= M”这个判断,连续好多天产量不变,达标的那一瞬间之后可能连续几天都维持同样的“真”,这完全没问题。二分只要求函数值从不真到真最多翻转一次,中间是平台期还是直线上升,都不影响正确性。
2.2 check函数怎么写:比二分模板更容易丢分的地方
二分答案题最容易丢分的点,其实不在二分本身,而在这个check函数。check函数翻译得好不好、边界处理对不对,直接决定了你的二分在搜一个什么怪物。
拿Autumn这类题来说,check(mid)的任务是回答一个问题:给定第mid天,你能不能算出所有树从第1天到第mid天累计产了多少资源,然后跟M比较。你要是真从第1天循环到第mid天,那又回到模拟的思路上了,复杂度直接从O(n log T)退化成了O(mid log T)。正确的做法是:对每一棵树,用数学公式直接算出它在mid天里面的总产量,O(1)时间算完一棵树,整个check就是O(n)。
比如某棵树每a天产一次果子,每次产b个。那么在第mid天之前,它产果的次数就是 floor(mid / a),总产量就是 floor(mid / a) * b。如果题目里设定“第一天就产、每a天产一次”,那次数就变成 (mid + a - 1) / a 之类的向上取整公式。这里的细节差异一定要读清楚题面,否则你算出来的产量早晚会差出一次,二分结果自然不对。再极端一点,如果题目有“仓库容量上限”,累计产量超过上限之后就不再增加了,那check函数里还要加一个类似 sum = min(sum + add, cap) 的截断操作。这些业务逻辑才是这道题真正的灵魂,写check的时候一定要把题目的每一个角落都扫一遍,确认没有漏掉条件。
我自己的习惯是,check函数只做一件事:根据传入的参数x,计算这个状态下是否满足题目的目标条件,返回bool值。所有计算过程全部放在这个函数内部完成,不要在二分主循环里塞任何其他逻辑。这样写的好处是,出bug的时候你可以单独调试check函数,用一个样例数据跑一遍,看看返回值和预期是否一致。
2.3 三种二分模板:边界问题一次性理清楚
写二分答案,最常见的问题永远是边界:while里到底写 l < r 还是 l <= r,mid到底用 (l+r)>>1 还是 (l+r+1)>>1,一旦写错就会出现死循环或者答案差1。我在这里把三种常用模板都列出来,你可以根据自己习惯选一个,然后一直用下去。
第一种写法是 l = 0, r = maxR,while(l < r),然后 mid = (l + r) >> 1,如果check(mid)成立,r = mid,否则 l = mid + 1,最后输出 r。这是“找第一个满足条件的值”的经典写法,适用于check(mid)为真时左边界要向右缩小的场�合。第二种写法是 l = -1, r = maxR + 1,while(l + 1 < r),mid = (l + r) >> 1,如果check(mid)成立,r = mid,否则 l = mid,最后输出 r。这种写法我愿称之为“最不容易写崩”的版本,因为循环终止条件 l+1 < r 保证了最终 l 和 r 之间只剩一个格子,天然不会死循环,而且区间两端都开,不用反复纠结边界到底包不包含。第三种写法是在有序数组里找特定的target,常用 l <= r 的闭区间写法,但它不太适合二分答案,因为答案题的区间含义通常不是“数组下标”,而是“可行解范围”。
我个人在所有二分答案题里都推荐第二种写法,也就是左开右开区间写法。原因很简单:你不需要在循环体内思考“mid是否要加1、减1”,因为 l 永远指向不满足的值,r 永远指向满足的值,循环退出时 r 就是答案。这个写法我第一次是跟一位学长学的,用熟了之后发现自己再也不怕边界问题了,强烈建议还没找到顺手模板的朋友试试。
3. 实操环节:从读题到AC的完整流程记录
3.1 数据建模:把题面翻译成二分答案的数学表达式
现在我们来模拟一次完整的做题过程,把Autumn这道题从头到尾梳理一遍。题面细节我按常见的果园题设定来演示:有n种生产装置(我们就叫它们“树”吧),第i棵树有一个生产周期 p_i,每次成熟后能收获 c_i 个果子,所有树从第1天开始运作,目标是让累计产量首次达到M,问最早是哪一天。
这里有个很关键的点:每棵树不是每天都有产出,它只在周期结束的那一天一次性产出。这样一来,“总产量”不是一个简单的每天累加,而是每一棵树在 x 天内的产出次数乘以每次产量,再加总。翻译成数学公式就是:
total(x) = Σ (floor(x / p_i) * c_i)
然后check(x)就等价于判断 total(x) >= M 是否成立。你看,一旦把这个公式写出来,整个题目就从“模拟每天发生了什么”变成“算一个带取整的函数值”了。我们二分的就是这个函数首次超过M的x值。
这里还有一个比较容易忽略的点:题目问的是“首次达到M的那一天是哪一天”,如果第0天就有初始库存,那初始库存也要加进去。我们在建模的时候可以单独用一个变量 base 表示初始库存,check函数判定的就是 base + total(x) >= M。这种小细节虽然不难,但漏掉任何一个都会让你在最简单的样例上就挂掉,千万不要觉得样例过了就万事大吉。
3.2 AC代码逐行拆解:C++实现的完整参考
下面我把这道题的C++完整AC代码写出来,然后逐段讲解。不同版本的题面细节可能略有差异,但整体框架可以直接套用。
#include <bits/stdc++.h> using namespace std; typedef long long ll; const int MAXN = 100000 + 5; int n; ll p[MAXN], c[MAXN]; ll M; ll base; bool check(ll x) { unsigned long long sum = base; // 初始库存 for (int i = 1; i <= n; i++) { // 第i棵树在x天内的产出次数 ll times = x / p[i]; sum += (unsigned long long)times * c[i]; if (sum >= (unsigned long long)M) return true; // 提前退出,防溢出 } return false; } int main() { ios::sync_with_stdio(false); cin.tie(0); cin >> n; for (int i = 1; i <= n; i++) { cin >> p[i] >> c[i]; } cin >> M >> base; if (check(1)) { // 特判:第1天就达到 cout << 1 << "\n"; return 0; } ll l = 1, r = 1; // 倍增找上界:先用翻倍的方式确定一个一定可行的天数 while (!check(r)) { l = r; r <<= 1; if (r < 0 || r > 4e18 / 2) { // 防止越界 r = 4e18; break; } } // 二分答案:左开右开模板 while (l + 1 < r) { ll mid = (l + r) >> 1; if (check(mid)) r = mid; else l = mid; } cout << r << "\n"; return 0; }这段代码的核心思路是:先用倍增的方式快速找到一个足够大的上界 r,然后在这个区间内二分。第1天特判是很多人都容易忽略的:如果第1天就已经达标了,直接输出1就好,不需要进二分。如果你不特判,l 初始值设为1,r 又从1开始翻倍,那 check(1) 为真的情况下会空转一轮或者直接输出错误结果,非常坑。
倍增找上界这个技巧,是我做二分答案时特别喜欢用的。你可能想:为什么不直接设 r = 1e18 然后开二分?答案很简单:第一,你设1e18必须在check里计算天数接近1e18时的总产量,这时候 sum 不溢出才怪,你得额外处理;第二,倍增找上界通常可以让 r 停在距离答案比较近的位置,这样二分查找的轮数更少,代码运行更快。比如真实答案是100万,r 经过20次翻倍就到约100万了,上下界区间一开始就很紧凑。
3.3 long long、上界与溢出的三个大坑
这段代码我给 sum 用了 unsigned long long,为什么?因为题目数据稍微刁钻一点,n 取1e5,p_i 取1,c_i 取1e9,M 取1e18,那么第1e18天的总产量就是 1e5 * 1e9 * 1e18 = 1e32,早就超出 long long 的范围了。虽然我们不会真的让 sum 累加到那么大才去判断,因为check里一旦 sum >= M 就立刻 return true,但当M本身就是1e18的时候,sum 在超过1e18的那一刻就会被截断,看起来好像没问题。但如果你忘记了提前退出,老老实实把 n 个数的产量全部累加完,那 sum 就会疯狂溢出,结果变成随机数,二分完全崩掉。
所以这里有三个大坑,每个都值得单独说一下。第一,中间乘法溢出:times * c[i] 这个操作,times 和 c[i] 都是 long long,乘出来的结果可能超过long long。在累加到sum之前,最好的做法是强制转换成 unsigned long long 再乘。第二,sum累加溢出:虽然提前退出可以缓解,但如果不小心某个测试点M特别大、n特别大,sum在退出之前已经超过了2^64,unsigned long long也会溢出。更稳妥的做法是直接用 __int128 作为sum的类型,这是GCC的扩展类型,中间运算随便造,只要最终结果不超过128位范围就行。第三,二分上界r本身溢出:r <<= 1 这种倍增操作如果一直翻下去,也会溢出,所以我在代码里加了 r > 4e18 / 2 的判断当作保护。
还有一个经常被忽略的点,就是“乘除的顺序”。比如你要算 x / p_i * c_i,因为 p_i 可能特别大,c_i 也可能特别大,正确的顺序是“先除后乘”,利用整除先缩小数值范围,避免不必要的溢出。如果你先乘后除,中间结果直接炸掉,算出来的东西就完全没意义了。这个习惯对于所有算法题都是通用的,尤其是涉及大数的统计题。
4. 常见问题与排查技巧实录
4.1 死循环、答案差1、超时的三大根源
做二分答案题,最常见的三个症状无非就是:死循环、答案比正确答案大1或小1、运行超时。这三个问题看着完全不同,但根源往往集中在几个地方。
死循环几乎都是循环条件写错导致的。如果你用 while(l < r) 又搭配 mid = (l + r) >> 1,然后当 check(mid) 为真时写 l = mid,就会出现当 l 和 r 只差1时,mid 等于 l,check(mid) 为真后 l 还是 l,永远死循环。解决办法就是换用 l + 1 < r 的写法,或者保证每次移动的都是某个边界且必变动。答案差1则往往是边界取错了:比如你找的是“第一个满足条件”的值,结果模板里写成了“最后一个不满足的值+1”,最后输出时也忘了处理初始边界。超时则通常是check里套了复杂度太高的操作,比如每次check都要排序一下、每次check都要重新初始化一个大数组,这就把原本 O(n log T) 的复杂度拉爆了。
如果你在自己机器上调试时发现这些症状,我建议你第一步不是读代码,而是先打印中间值。把二分过程中每一次的 l、r、mid、check(mid) 都打出来,很快就能看到是循环条件的问题还是check函数的问题。绝大多数情况下,边界错误一眼就能在这样输出的过程中被抓到。
4.2 对拍器与暴力程序:验证check函数正确性的最快方式
我一直觉得,写二分答案题最强大的调试方式不是人工盯代码,而是写一个暴力的小程序去对拍。具体做法特别简单:先写一个完全按照题面模拟的暴力版本,从第1天循环到答案上限,逐天计算产量,直到达到M,输出这一天。然后写一个随机数据生成器,每次生成 n 很小的数据(比如 n <= 5,天数上限 <= 100),同时喂给暴力程序和二分程序,比较两个程序的输出是否一致。只要随机数据量大一点,比如跑几千组,你的check函数里哪怕藏着一个特别隐蔽的边界 bug,也一定会被揪出来。
这个方法在竞赛圈里几乎是公开的秘密,但我发现很多新手在刷题时完全不知道。有时候代码卡在一个样例上死活过不去,如果身边没有题解可看,写个暴力对拍往往几分钟就能定位问题。尤其是二分答案这种“错误不会直接爆WA,而是会在特定边界上爆出答案差1”的题,对拍的价值比任何调试技巧都大。我在Autumn这道题上就是用这个方法发现了我check函数里对“每a天产一次”这个周期的取整写错了——我曾经直接用了 x / p_i,忽略了“第1天也会产”这个条件,导致所有mid都会被算少一次产量。对拍跑出差异的那一刻,我差点拍桌子,因为这种逻辑错误靠干瞪眼真的很难发现。
4.3 现场翻车实录:我在这个题上踩过的三个真实大坑
为了让大家少走弯路,我把当年在类似题目上实际踩过的坑整理一下。第一个坑是固定上界带来的溢出。我当时嫌倍增找上界麻烦,直接用 r = 1e18 开二分,结果check里有一棵树p_i特别小,x特别大,times*c[i]直接爆掉,导致check判定完全错乱。后来把 r 的寻找方式改成倍增,同时乘法用128位承接以后,这个问题就再也没出现过。第二个坑是初始库存忽略不计。题面里明明写到“果园已经储备了一批果子”,我却想当然地以为初始库存是0,结果所有测试点都比正确答案多了好几天。这种教训很简单:读题时对条件逐字抠,尤其是数字和定语。第三个坑是二分区间左端点设计不合理。我当时上来就用 l = 0,但check(0) 的逻辑写错了,直接报错。后来我把 l 的语义固定为“一定不满足的天数”,初始化成0,并把check(1)特判做掉,整个逻辑就顺了。
这三个坑总结出来其实是同一句话:二分答案题的代码不难,难在把题面的每一个限制条件都完整、准确地塞进check函数里。那些WA在测试点13、14上的同学,通常不是二分写错,而是check里漏了一个条件。
5. 不止为了AC:二分查找思想还能用在哪
5.1 从竞赛题到日常工作:二分查找不止是算法题里的“玩具”
说句实话,二分查找在工程实践里出现的频率,比很多人想象的都要高得多。你写日志系统时,要在一大坨按时间排序的日志里找到某一天的第一条错误日志,这就是二分。你在性能测试里,要找出某个接口在并发量达到多少时开始超时,这也构成一个单调关系:并发量越大,超时越多。你可以用二分逼近那个临界并发数,而不是一个一个往上试。
我做项目时,有一次需要在一个巨大的存储系统里定位数据损坏的最早时间点。这个系统每天会生成一个快照,而损坏从某一天开始持续出现。由于快照数量特别大,而“是否损坏”的性质又是单调的——之前全好,之后全坏——我直接在快照列表上写了个二分定位,十几行代码解决问题。当时旁边的同事还在用逐天检查的方式跑脚本,过来看到我已经定位完,第一反应是“你怎么知道一定是这一天”。我告诉他,这不是猜测,这是二分查找。这类问题在工程里真的很多,你只要看到的场景里满足“前一段是A状态、后一段是B状态、且两段单调变动”的特征,脑子里就自动弹出二分的模型,会非常省事。
所以,别把二分查找当成只活在竞赛题库里的技巧。它其实是人类解决问题的一个基本思维模型:在未知环境中用最少的尝试次数定位边界。好比你在黑暗中摸一根灯绳,你不需要从门口一寸一寸摸过去,你只需要每次都跳到剩余长度的中间,摸一下,就知道灯绳在这半边还是那半边。算法竞赛训练的就是这种在约束下快速定位的直觉。
5.2 几个值得一试的进阶变式
如果你把Autumn这道题做透了,对二分查找产生了兴趣,我建议你再尝试几个变式,它们的基础思维是一样的,但代码细节各有不同。
第一个是三分查找,用于求一个凹函数或者凸函数的极值点。它和二分答案的区别在于,二分答案找真假分界点,三分查找找函数峰值,每次比较两个mid点然后塞掉一段区间。第二个是二分答案和数据结构结合,典型的就是“二分数值 + 树状数组/线段树计数”,用来解决第k小、逆序对数量限制等问题。第三个是lower_bound/upper_bound的高阶使用,比如在多重集的场景下找某个区间内有多少个数落在[L,R]范围内,可以在排序后的数组上先lower_bound(L)再upper_bound(R),两个迭代器一减就是答案。这种组合我几乎每周都在用。
我个人经验是,比起死记硬背各种模板,不如先把“单调函数的真假分界”这个思维模型打磨透彻。Autumn这道题的价值不在于让你会做一道果园题,而在于让你以后看到任何“最早、最晚、最短、最少”的表述时,大脑自动开始思考:这个变量是否单调?能不能二分?有了这个条件反射,你刷再多的二分题目都不会觉得白费,因为它们只是在反复强化同一种思维方式而已。