☰
二分答案套路详解:从LeetCode 875“爱吃香蕉的珂珂”吃透四道热题
2026/10/8 9:45:02 网站建设 项目流程

刷LeetCode热题100这个系列,我推到了第五期。说实话,到这一期我已经不太想按题目顺序一篇篇刷了,反而是把同一种套路的题归拢在一起,一次吃透一类的收获更大。这一期把我彻底带跑偏的,是一道名字看起来有点喜剧的题:爱吃香蕉的狒狒。好多人第一次看到这个标题以为是个模拟题或者贪心题,结果点进去才发现,这题几乎是二分查找从“在有序数组里查下标”跨到“在答案空间里直接二分”的第一道门槛。它在一个高频题单里经常被排在很靠前的位置,也是很多公司笔试手撕环节的常客。理解了这题,等于顺手解锁了至少四道同套路的热题,所以我决定这一期专门把它和它的“亲戚们”讲透。

1. 热题100里的二分题,其实分成两个完全不同的物种

1.1 经典二分和二分答案:别混为一谈

很多人一看到“二分”两个字,脑子里全是17、34、35、74这种题:数组有序,然后对下标做二分,每次砍掉一半区间去找目标值。这类题的核心是“有序数组上的查找”。

但热题100里还有另一类二分,长得完全不一样:数组可能根本无序,也不存在“目标下标”这个概念,题目问的是一个数值答案,比如“最小速度”“最大容量”“最少天数”。这类题的做法是把答案本身当作搜索空间,对这个数值范围做二分。前者我习惯叫“二分下标”,后者叫“二分答案”。

以热题100为范围,这两类的代表差不多是这样:

类型代表题二分对象判定方式
二分下标34. 在排序数组中查找元素的第一个和最后一个位置数组下标比较 nums[mid] 和 target
二分下标33. 搜索旋转排序数组数组下标判断 mid 落在哪个有序段
二分答案875. 爱吃香蕉的珂珂速度 k(数值)计算 f(k) 是否满足时限
二分答案1011. 在D天内送达包裹的能力运载能力 capacity模拟装船天数是否 <= D
二分答案410. 分割数组的最大值子数组和的上限能否分成不超过 m 段

这两类的思维方式差异非常大。二分下标的核心是“怎么判断该往左走还是往右走”,二分答案的核心变成了“怎么写出一个判定函数,判断当前这个答案可行不可行”。后者才是笔试面试里真正的重头戏。

1.2 为什么二分答案是面试高频

我个人的观察是,二分答案类题目在面试里的地位非常特殊。它的代码量通常不大,核心逻辑就一个 while 循环加一个 check 函数,但恰恰是那个 check 函数的写法、边界取法、单调性判断,能把一个候选人的思路是否清晰试出来。所以很多公司的第一轮手撕题、笔试压轴题都爱出这个。

而且这类题有个共性:你一旦找到“答案是某个数”这个视角,思路会顺很多。难就难在很多人刷题时习惯往熟悉的算法上去套(DFS、DP、贪心),就是想不到二分,结果卡死在暴力枚举上。这也是为什么我觉得应该在热题100系列的第五期里,专门把这个套路拎出来讲明白。

2. 爱吃香蕉的狒狒:题目拆解与单调性推导

2.1 题面还原成大白话

先把这个题讲清楚。力扣875题,英文名是Koko Eating Bananas,中文站标准译名叫“爱吃香蕉的珂珂”,不过民间喊成“狒狒”的特别多,反正大家知道是这题就行。

题目大意是这样:有一排香蕉堆,每堆的香蕉数量记录在数组 piles 里,第 i 堆有 piles[i] 根。一只叫珂珂的猴子要在 h 小时内把香蕉吃完。她吃香蕉的速度是每小时 k 根,但有一个规则:她同一时间只从一堆里吃,如果这一堆剩下的香蕉不足 k 根,她就把这一堆全吃掉,并且这个小时也算用掉了。现在要求找到最小的 k,使得珂珂能在 h 小时内吃完所有香蕉。

输入范围很有讲究:piles.length 最多 10^4,每堆最多 10^9 根,h 最大 10^9,并且题目保证 h 不小于堆数。这个“h >= 堆数”的约束是隐藏线索,后面会用到。

举个例子:piles = [3, 6, 7, 11],h = 8。如果 k = 4,她们每小时吃4根,三根那堆1小时吃完,六根那堆需要2小时,七根那堆需要2小时,十一根那堆需要3小时,总时间 1+2+2+3 = 8,刚好满足。如果 k = 3,总时间变成 1+2+3+4 = 10,超了。所以答案是4。

2.2 为什么直接模拟会挂

最粗暴的思路是从 k = 1 开始一直往上试,每试一个 k 就遍历一遍 piles 算出总时间,第一个满足的 k 就是答案。这个正确性是没问题的,问题出在复杂度上。

最坏情况下 k 从1枚举到 max(piles),也就是 10^9,每轮还要遍历 10^4 个元素,总操作量是 10^13 量级,跑完一次最坏样例基本等于让程序冬眠。就算Koko一只一只啃到天亮,你的程序也等不到那个 k 出现。

这里可以打个比方:让你猜一个1到1000之间的数,正常人的反应是每次猜中间,而不是从1开始一个个往上报。二分答案的思维就是把这个常识搬进算法:既然答案可能值的范围是连续的,那我每次取中间值验证一下,一次能排除一半的范围。

2.3 单调性:这道题能二分的根

二分不是万能的,能用二分的本质条件是答案区间上存在一个单调的性质。这个题里,定义一个函数 f(k) = 以速度 k 吃完全部香蕉所需的总小时数,那么 f(k) 是一个单调递减的函数:k 越大,每小时吃得越多,总时间只会不变或者变短,绝不可能变长。

目标也随之清晰:在所有 k 里找最小的一个,使得 f(k) <= h。如果你把 k 从1往大了取,会看到 f(k) 一路从很大降到接近 n(堆数),过了某个临界点之后就一直 <= h 了,我们要找的就是这个临界点的左边界。

这是典型的“最小值可行解”模型。判定标准非常明确:check(mid) 返回 true 时,说明 mid 这个速度可行,答案在左侧更小的区间里找;返回 false 时,答案在右侧更大的区间里找。

2.4 判定函数是整道题的心脏

二分本身没什么花样,check 函数才是考点。这个题里,计算 f(k) 的公式是:

对每一堆 pile,需要的整数小时数是 ceil(pile / k),也就是把 pile 除以 k 向上取整。注意“每小时最多吃一堆”的限制:就算这堆只剩1根,只要 k >= 1,这一个小时也算吃完了这堆,剩下的时间不能拿来吃下一堆。

代码写出来是这样:

def can_finish(piles, k, h): hours = 0 for pile in piles: hours += (pile + k - 1) // k if hours > h: # 剪枝:超时直接返回,不用算完 return False return hours <= h

这里 (pile + k - 1) // k 是向上取整的标准写法。很多第一次写的人会忘了向上取整,直接 pile // k,然后答案全错。还有一个隐蔽的细节:当 pile <= k 时,(pile + k - 1) // k 算出来是1,正好对应“这堆不足一口,但也要耗时1小时”的规则。

注意:hours 的累加结果可能非常大。piles[i] 最大 10^9,10^4 堆,就算每次只耗时1小时,hours 也可能到 10^4;但如果 k 很小,比如 k=1,hours 会是所有 pile 的和,也就是 10^13 量级。在 C++ 里必须用 long long。

3. 完整题解:二分闭环与边界取舍

3.1 左右边界到底怎么定

边界是这道题最容易让人犹豫的地方。左边界很简单:速度 k 最小是1,总不能0根0根地吃。右边界我见到的有两种写法:

  • 右边界取 max(piles):因为 k = max(piles) 时,最满的那堆也只要1小时,总时间正好等于堆数 n,而题目保证 h >= n,所以这个速度一定是可行的。答案不会超过它。
  • 右边界取 max(piles) + 1:这是给“左闭右开”二分模板准备的,保证初始时右边界是一个必然可行的值,方便后面用 left < right 不需要再维护 ans 变量。

我个人的习惯是取 max(piles),然后用带 ans 的 left <= right 写法。原因很简单:答案范围天然的左闭右闭区间就是 [1, max(piles)],肉眼可见 right 一定可行,那就不需要额外设计一个“哨兵”右边界。不过两种写法都能过,关键是心里要知道自己模板的语义。

3.2 三种 while 循环写法的实测对比

先把三种主流写法摆出来:

写法A:left <= right + ans 记录

int left = 1, right = 0, ans = 0; for (int p : piles) right = max(right, p); while (left <= right) { int mid = left + (right - left) / 2; if (canFinish(piles, mid, h)) { ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans;

写法B:left < right + 双向逼近

int left = 1, right = maxPile; while (left < right) { int mid = left + (right - left) / 2; if (canFinish(piles, mid, h)) { right = mid; } else { left = mid + 1; } } return left;

写法C:左闭右开模板

int left = 1, right = maxPile + 1; while (left < right) { int mid = left + (right - left) / 2; if (canFinish(piles, mid, h)) { right = mid; } else { left = mid + 1; } } return left;

写法A 的好处是直观,ans 变量保底,就算二分条件搞错了,至少最后一次可行答案还留在 ans 里。写法B 是现在最流行的最小值模板,关键是理解 right = mid 和 left = mid + 1 的配合,它要求初始时 right 必须是可行的。写法C 只是把右边界偏移了一下,逻辑上跟B一样。

我实测这三种写法在热题100相关的测试用例上都能通过,运行时间也几乎一样。真正重要的是别来回切换模板,选定一种以后所有的二分答案题都统一用这一种,形成肌肉记忆。

3.3 完整代码:C++ 和 Python

C++ 版本:

class Solution { public: int minEatingSpeed(vector<int>& piles, int h) { int left = 1, right = 0; for (int p : piles) right = max(right, p); while (left <= right) { int mid = left + (right - left) / 2; if (canFinish(piles, mid, h)) { right = mid - 1; } else { left = mid + 1; } } return left; } private: bool canFinish(vector<int>& piles, int k, int h) { long long hours = 0; for (int p : piles) { hours += (p - 1) / k + 1; // 等价于向上取整,且不溢出 if (hours > h) return false; } return true; } };

这里有一个很多人会忽略的细节:我用的向上取整是 (p - 1) / k + 1,而不是 (p + k - 1) / k。原因很简单,p + k - 1 在 int 范围内虽然不会溢出(最大 10^9 + 10^9),但如果你把这道题改成其他变体,或者推广到更大数据范围,提前养成用减法避免加法的习惯能少踩很多坑。两种写法数学上完全等价。

Python 版本:

class Solution: def minEatingSpeed(self, piles: List[int], h: int) -> int: left, right = 1, max(piles) def can_finish(k: int) -> bool: hours = 0 for p in piles: hours += (p + k - 1) // k if hours > h: return False return True while left <= right: mid = (left + right) // 2 if can_finish(mid): right = mid - 1 else: left = mid + 1 return left

3.4 复杂度与手算验证

时间复杂度是 O(n log(max(piles)))。n 是堆数,每次 check 要遍历一遍 piles,二分次数是 log(10^9) 约等于 30 次,就算 n 是 10^4,总操作量也就是 30 万次循环,对于 OJ 来说非常轻松。空间复杂度 O(1),只需要几个变量。

拿示例手算一遍确认逻辑。piles = [3, 6, 7, 11],h = 8:

  • k = 6(中间值)时:1 + 1 + 2 + 2 = 6,可行,答案应该 <= 6
  • k = 3(缩小到左半)时:1 + 2 + 3 + 4 = 10,不可行,答案应该 > 3
  • k = 4 时:1 + 2 + 2 + 3 = 8,可行,答案应该 <= 4
  • k = 3 已确认不可行,所以答案是 4

整个流程非常顺,没有歧义。这就是二分答案题的魅力:判定函数写对,结果完全确定。

4. 从狒狒出发:热题100同套路四连击

875 这道题吃透之后,下面这几道热题几乎是同一个骨架换皮。我建议你把这几道放一起刷,一天之内就能建立“二分答案”这套思维模型。

4.1 1011. 在D天内送达包裹的能力

题目背景是传送带上一串包裹重量 weights,船运载能力为 capacity,要求在 D 天内运完,每天按顺序装包,装不下就等第二天。求最小的 capacity。

转换成二分答案:左边界是 max(weights),因为单件货物不能拆;右边界是 sum(weights),因为一天全装完一定能运到。判定函数是模拟装船:按顺序累加当前重量,超过 capacity 就开新的一天,最后看用了几天,是否 <= D。

这和 875 的对应关系非常工整:吃的速度对应运载能力,每堆耗时对应每天装货,总时间对应总天数。875 的 check 算的是“小时数”,1011 的 check 算的是“天数”。

def can_ship(weights, capacity, D): days = 1 cur = 0 for w in weights: if cur + w > capacity: days += 1 cur = 0 cur += w if days > D: return False return True

4.2 410. 分割数组的最大值

这题在热题100里也很常见:给一个数组 nums,要求分成连续的 m 段,最小化“各段和的最大值”。

这里就需要转换一下思路了。我们不是在找某个元素,而是在找一个“段和上限”x,使得存在一种分割方式,让每一段的和都不超过 x。一旦 x 确定,检查逻辑非常简单:从左到右贪心分段,当前段超过 x 就切断,数一数总共分成几段,是否不超过 m。

左边界是 max(nums),右边界是 sum(nums)。为什么右边界是 sum?因为 x = sum(nums) 时,整段不分,分成1段,只要 m >= 1 就可行。这题和 1011 本质上是一道题,区别只是 1011 的转化视角是“船运包裹”,410 的视角是“切数组”。

4.3 1482. 制作m束花所需的最少天数

这题稍有一点变化。花园里 bloomDay[i] 表示第 i 朵花开放的时间,要求用相邻且已开放的花做成花束,每束需要连续 k 朵,总共要做 m 束,求最少等几天。

二分对象变成了“等待天数 day”。判定函数里,遍历数组统计连续已经开放的花的数量,一旦连续长度达到 k,就做成一束,计数加一,连续计数清零。最后判断总花束数是否 >= m。

这道题和前面几道最大的区别是有一个“相邻”限制,导致 check 函数里还得维护一个连续窗口的状态,不能只是一味累加。但它依然是二分答案:天数越大,开花越多,能做的花束只可能变多,单调性依然成立。

这里有一个很典型的易错点:如果 bloomDay.length < m * k,直接返回 -1,因为就算等到天荒地老,花的总数都不够做 m 束,二分都没必要做。很多人在笔试里卡在这里,就是因为忘了这个提前判断。

4.4 怎么一眼识别“这是二分答案”

刷完上面四道题,你会发现在看到某一类题目时,基本能条件反射想到二分答案。我总结出来的识别特征有三个:

  • 题目问的是“最小的X”“最大的X”“最少多少天”“最大容量是多少”这种数值性问题,而不是问具体方案。
  • 答案如果取一个值 x,那么存在一个判定函数 check(x),能在多项式时间内判断“x 是否可行”,而且这个判定天然有单调性。
  • 暴力枚举 x 的范围非常大,大到无法直接遍历,但二分能把复杂度压到 log 级别。
  • 文字里如果出现“最大化最小值”或“最小化最大值”这类词,大概率就是二分答案,但要记得自己验证一下单调性再动手。

5. 我摔过的坑:这类题最容易错的地方

5.1 整数溢出是最阴的坑

875 这道题表面上看数据范围是 10^9,int 最大值是 2.1×10^9,好像不会溢出。但如果你在 check 函数里写 hours += (pile + k - 1) / k,然后 hours 是 int,当 k = 1 时,hours 累加的量级是所有 pile 之和,也就是 10^13 量级,int 直接爆掉。C++ 必须用 long long,Python 没有这个烦恼,但 C++ 选手很容易中招。

5.2 向上取整的写法别踩运算符优先级的坑

(pile + k - 1) / k 这个写法在 Python 里是安全的,在 C++ 里只要保证 pile + k - 1 不溢出即可。但我更推荐 (pile - 1) / k + 1,它连溢出的可能性都从根上消除了,而且语义上更贴近“不足 k 根也算1小时”的规则。

5.3 二分写完了却死循环

很多人第一次写 left < right 的模板时,在 mid = left + (right - left) / 2 这里会犹豫:为什么不是 (left + right) / 2?其实当 left 和 right 都很大时,left + right 可能溢出,一旦溢出 mid 变成负数,二分直接崩。用 left + (right - left) / 2 是标准防溢出写法。

真正死循环的场景是:在找“最小值可行解”时,你写了 left = mid 而不是 left = mid + 1。当区间缩到 [left, right] = [n, n+1] 时,mid 永远等于 left,check(left) 又不满足,left 就一直卡在原地,死循环。排查经验:只要看到程序不结束,先检查 left 的更新是不是 mid,如果是,马上改成 mid + 1。

5.4 check 函数里的剪枝不要省

在875的 check 里,一旦 hours > h,完全可以提前 return False,不需要把剩余堆全部算完。这个优化在数据小的时候感觉不出来,但在笔试极限数据下,可能直接把总时间降低一半以上。1011 的 check 也是同理,一旦 days > D,立刻返回 false。

5.5 边界样例永远是 n 的邻域

二分答案题最容易错的就是边界样例:k = 1、k = max、h 恰好等于边界。我的习惯是代码写完先手动试三组:答案就是左边界、答案就是右边界、答案在正中间。875 里答案等于1的情况要求 n == h,也就是每堆1根;答案等于 max 的情况是 h == n。把这两组极限代进代码,如果逻辑没崩,这道题的边界就算守住了。

6. 我的刷题节奏:怎么把这类题从30分钟压缩到10分钟

6.1 我第一次刷875的真实状态

说实话,我第一次做这道题的时候,花了大半个小时。当时我先想到的是优先队列或者贪心,觉得把每次吃最多的一堆吃掉会快一些,折腾半天发现根本不对——规则是同时间只吃一堆,而且按堆吃,跟贪心没有关系。后来翻题解看到二分答案,才反应过来自己方向从一开始就错了。

那次失败给我的教训是:刷题遇到瓶颈,不要急着写代码,先把“答案是一个数”这个条件写在纸上,再看这个数能不能二分。这个转念特别重要,很多人卡题不是因为代码不会写,而是因为思维定势。

6.2 我现在的五步刷题流程

现在再遇到这类题,我基本固定走五个步骤,十分钟左右能完成:

  1. 读题后先判断:这个题的答案是某个数值吗?这个数值有明确的上下界吗?
  2. 写出可行性的单调性判断:答案 x 变大,要么从不可行变可行,要么从可行变不可行。
  3. 定边界:左边界设在理论最小值,右边界设在必然可行或必然不可行的位置。
  4. 写 check 函数:这是唯一需要动脑的部分,检查它是不是足够快、有没有提前剪枝。
  5. 套自己熟悉的二分模板,然后拿边界样例手算一遍。

这个流程最大的价值是让我把“写代码”这个动作排在最后一个环节,前面四个环节是纯思考,代码反而是最机械的部分。

6.3 模板有用,但别被模板绑架

二分答案的模板确实非常好用,但我不建议完全不理解原理地背模板。面试官一旦追问“为什么 right = mid 而不是 mid - 1”,如果你答不上来,这道题基本就前功尽弃了。

我的习惯是,每刷完一道二分答案题,就在注释里写一段为什么用这个边界。比如875的注释我会写:right 初始化为 max(piles),因为它一定可行,用 left <= right 模板时,right = mid - 1 是为了收敛到最小可行值。写注释的过程其实就是强制自己把逻辑理清楚。

6.4 这个系列的推荐刷题顺序

如果你正好也在刷热题100,我建议把这几道题按照这个顺序来:

875 爱吃香蕉的珂珂(理解二分答案和 check)→ 1011 在D天内送达包裹的能力(相同的结构,换一个判定场景)→ 410 分割数组的最大值(把“段和”当作答案)→ 1482 制作m束花所需的最少天数(check 里引入连续窗口状态)。

按这个顺序刷,你会发现四道题共用同一个骨架:while 循环 + check 函数,区别只在 check 内部怎么计算“总数”或“天数”。刷完后可以再回头看一眼875,会觉得它已经变成一道基础题了。

最后分享一个小技巧:我在刷完这类题后,会把 check 函数单独复制出来,用几个小样例把 x 从1到10手动打一遍,观察它从不可行到可行的拐点。这个习惯帮我避开了很多边界错误,也让我对“单调性”这三个字有了真正的体感——它不是数学定义,而是每次跑程序时你能看到的实际趋势。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询