开始认真刷LeetCode这件事,我拖了很久。倒不是觉得算法不重要,而是每次打开题库,看到三位数的题目编号和一堆“通过率惨淡”的中等题,总有种无从下手的压迫感。直到上个月我给自己定了个规矩:不求一天十题,只求每天一道,凑满100道就写一篇阶段性笔记。于是就有了“Leetcode(1/100)”这个系列的第一篇,而这篇我打算好好聊聊“073爱吃香蕉的狒狒”这道题。
如果你正在刷LeetCode热门100题,或者刚开始用二分查找练手,这道题几乎是绕不过去的经典。它表面是个“猴子吃香蕉”的小故事,实际上考的是二分答案这个核心算法模型的落地能力。我见过不少人在做这道题时栽在边界条件上,也有人根本想不到要用二分。这篇文章我会把自己的解题思路、代码实现、运行时踩过的坑,以及怎么把它推广到同类问题上,一分不差地摊开讲,希望对正在刷题的你有点帮助。
1. 为什么“1/100”从二分查找开始
1.1 刷题清单怎么选:热门100题到底该怎么用
先交代一下背景。我在规划这100题的时候,并没有老老实实按题号顺序往下刷,而是按专题拆着来。LeetCode热门100题是一份非常经典的清单,里面覆盖了数组、链表、树、动态规划、贪心、二分查找这些常考考点,适合用来做系统训练。但很多人拿到清单后有个误区,就是把它当成“顺序表”,从第1题一路做到第100题。你这么做的结果是:链表题做完三题,突然跳到动态规划,思维还没切换过来,又回到哈希表,效率非常低。
我的做法是先把清单里的题目按知识点归类,再按“简单到中等”的坡度逐类击破。比如数组类先做两数之和、三数之和,再做盛最多水的容器;链表类先做反转链表,再做环形链表;二分查找类则从“爱吃香蕉的狒狒”这种典型模型入手,再慢慢延伸到“在D天内送达包裹的能力”这类稍微复杂的变体。
爬下这篇你可能会问,为什么第一篇不是两数之和这种“Hello World”级题目,而是选了073这道中等题?因为我很清楚自己想突破的不是“会不会写循环”,而是“能不能一眼识别出二分答案的模型”。两数之和考的是哈希表的空间换时间,而吃香蕉这道题能真正帮你建立“对答案进行二分”的思维习惯。这个思维一旦打通,后面很多所谓的难题其实都是换皮。
1.2 这道题的整体思路与选型逻辑
先看一下题目本身。Koko是一只狒狒,面前有N堆香蕉,每一堆有piles[i]根香蕉。它每小时可以选一堆,吃掉K根香蕉;如果这一堆不够K根,它就把这堆吃完,但这一小时内不会再吃另一堆。现在要求在H小时内吃完所有香蕉,问最小速度K是多少。
这个描述看起来像个模拟题,很多人的第一反应是:那我从K=1开始试,每小时模拟一遍,看总时间是不是小于等于H,不行就K+1再试。理论上这么干肯定能出答案,但LeetCode的测试数据里,piles的长度最大能到10^4,每堆香蕉数量最大能到10^9,H可以到10^9量级。暴力枚举K从1到max(piles)意味着可能枚举上亿次,每次还要遍历一遍所有堆,直接超时。
那为什么能用二分?因为这里有一个非常关键的单调性:吃香蕉的速度越快,花的时间一定越短。K=1时耗时最长,K=max(piles)时耗时最短。我们要求的是“满足耗时≤H”的最小的K,这正好落在“单调函数上找边界”的模型里。所以在1到max(piles)这个区间内对速度做二分搜索,每次用“当前速度下到底要花几个小时”这个判定函数来缩小范围,最终就能以O(N log M)的复杂度收敛到答案。M是香蕉堆里的最大值,N是堆数,这个复杂度在本题的约束下完全可以跑满。
这套思路就是“二分答案”的经典套路:先确定答案的可行区间,再写一个检查函数,最后在区间里二分缩圈。
2. 核心细节解析:073这道题到底在考什么
2.1 题目约束与边界条件
我见过很多人在讨论区问,为什么二分的右边界直接取max(piles)而不是取一个更大的数?这其实是一开始理解这道题的关键。速度K的含义是“每小时最多吃掉多少根香蕉”,如果K已经大于等于最大那一堆的数量,那不管面对哪一堆都能在一小时内吃完这一堆。所以即使你把K继续增大到100万,总耗时也不可能降得更低。
我们验证一下:假设最大堆有m根,当K=m时,任意一堆piles[i]需要的用时是ceil(piles[i] / m),因为所有堆都不超过m,所以每一堆的耗时都是1,总耗时恰好等于堆的数量N。再往上提高K,堆数不变,总耗时仍然是N。也就是说K=m之后的区域已经没办法让总耗时更小,答案的最优值必然落在1到m之间。
这里还有一个所有新手都容易忽略的边界:K的下界必须是1,而不是0。速度是0意味着狒狒根本不工作,这道题在数学上永远吃不完,所以左边界的语义必须从可能的最小值1开始。如果你把left写成0,那么二分执行到某个阶段时可能会让mid=0,一旦进入canEat(0)的判断,就会出现除零错误,这是很典型的隐蔽bug。
还有一点需要注意:H的约束。题目保证n <= H,其中n是香蕉堆数。这个条件的意思是即使K取无穷大,每小时只吃一堆,也要H小时才能吃完,所以如果H<n的测试用例出现,说明输入本身就无解。只不过题目约束已经排除了这种数据,我们不需要在代码里额外判断,但如果你把这个函数封装出来给其他场景用,最好加一层防御校验。
2.2 判定函数canEat的写法,决定你能不能过
二分的主体框架其实都差不多,但真正区分一个人有没有吃透这道题的,是canEat这个检查函数怎么写。这里的计算逻辑是:遍历每一堆,计算在当前速度K下,这一堆需要多少小时才能被吃完,然后累加判断总耗时是否小于等于H。
单堆耗时的公式是ceil(piles[i] / K),这个向上取整很容易写错。我用过两种写法,第一种是整数运算技巧:(piles[i] + K - 1) / K,也就是分子加上K-1再整除K。第二种是浮点运算配合ceil,但我不推荐,因为piles[i]和K都可能到10^9,浮点数在极大值下会出现精度误差,可能让你二分的结果差1,这1的差距在LeetCode上就是Wrong Answer。
我自己的习惯是全程整数运算,不用浮点数,这样既快又稳。整个判定函数的时间复杂度是O(N),N是堆数,二分过程中这个函数会被调用大约log(max(piles))次,大概30多次,整体计算量很小。
2.3 二分模板选择:左右闭区间怎么处理
二分查找的模板在竞赛圈里各派各法,有左闭右开,也有左闭右闭。我在做这道题时用的是左闭右闭的写法,也就是区间[left, right]始终包含潜在的答案。初始化时left=1,right=max(piles),每次计算mid,调用canEat(mid)判断当前速度是否可行。
如果是可行的,说明mid速度下能按时吃完,那么答案可能是mid,也可能是更小的速度,所以需要把right调整到mid来缩小范围,继续向左逼近。注意这里不要写成right=mid-1,因为你还不确定mid-1是否可行,直接减1可能会漏掉正确答案。反过来,如果不可行,说明mid速度太慢了,那么答案一定大于mid,此时需要把left更新为mid+1。这个“可行时右边界不缩1,不可行时左边界缩1”的策略很关键,能保证循环结束时left就是最小可行速度。
有些教程喜欢用while (left < right)配一个mid不调整的版本,但如果你刚开始刷题,我建议先把“闭区间+边界手动调整”这套玩明白,再去尝试其他流派。因为这一套的每一步都有清晰语义,调试起来也直观。
3. 实操过程:完整代码实现与运行验证
3.1 Python版本实现:从暴力到二分
先给一个最直观的暴力版本,方便你体会为什么必须优化。暴力思路是枚举速度K从1到max(piles),对每个K算一遍总耗时,找到第一个满足条件的K就返回。
from typing import List import math def minEatingSpeed_brutal(piles: List[int], h: int) -> int: for k in range(1, max(piles) + 1): total_hours = 0 for pile in piles: total_hours += math.ceil(pile / k) if total_hours <= h: return k return -1这段代码逻辑完全正确,但如果你把它丢进LeetCode,面对大规模数据会直接TLE。原因就在于外层循环的范围可能到10^9,哪怕每次遍历的开销很小,乘在一起也扛不住。
再来看看二分优化后的完整版本,这也是我最终提交的代码:
from typing import List def can_eat_all(piles: List[int], speed: int, h: int) -> bool: hours = 0 for pile in piles: hours += (pile + speed - 1) // speed if hours > h: return False return hours <= h def minEatingSpeed(piles: List[int], h: int) -> int: left, right = 1, max(piles) while left < right: mid = (left + right) // 2 if can_eat_all(piles, mid, h): right = mid else: left = mid + 1 return left我在can_eat_all里做了一个小优化:累加hours的时候提前判断,如果已经超过了h,就直接返回False,不再计算剩下的堆。这在面对极端大数的时候能省不少时间,虽然理论上二分的时间复杂度没变,但常数更小,实测跑起来也更快。
3.2 C++版本实现:注意整型溢出
如果你用C++刷题,需要注意一个细节:piles[i]和speed都是int类型,但乘法或加法可能超过int范围。虽然这里用的是除法,(pile + speed - 1)在极端情况下可能到2*10^9,还在int范围内,不过一旦遇到更复杂的变体(比如速度和时间相乘),就非常容易溢出。
保险起见,我建议把相关变量都声明成long long,或者直接用long long做中间计算。
class Solution { public: bool canEatAll(vector<int>& piles, long long speed, int h) { long long hours = 0; for (int pile : piles) { hours += (pile + speed - 1) / speed; if (hours > h) return false; } return hours <= h; } int minEatingSpeed(vector<int>& piles, int h) { int maxPile = 0; for (int pile : piles) { maxPile = max(maxPile, pile); } long long left = 1, right = maxPile; while (left < right) { long long mid = left + (right - left) / 2; if (canEatAll(piles, mid, h)) { right = mid; } else { left = mid + 1; } } return (int)left; } };这里额外提一下,C++版本里我写了mid = left + (right - left) / 2而不是(left + right) / 2,是为了防止left+right本身溢出。虽然这道题数据范围不太可能溢出,但这是一个好习惯,尤其在处理更大范围的数据时能避免莫名其妙的bug。
3.3 测试用例怎么设计,才算真的理解了
我写完代码后不会直接提交,而是先在本地跑几组有代表性的测试用例。简单说,我会准备四类数据:最小规模回归、全是小堆的场景、只有一堆的超大堆、以及刚好卡在h临界值上的数据。
第一类是piles=[3, 6, 7, 11], h=8,这是题目自带的样例,答案应该是4。第二类是piles=[1, 1, 1], h=3,这种场景下每一堆都需要单独一小时,答案就是1,主要用来验证下边界有没有问题。第三类是piles=[1000000000], h=1,只有一堆但数量极大,答案应该是1000000000,用来验证右边界和大数处理。第四类是piles=[2, 2], h=4,这个答案也是1,因为速度1时2小时吃完第一堆,再2小时吃完第二堆,刚好4小时。
把这些用例放到代码里跑一遍,如果都能通过再提交LeetCode,基本就稳了。很多人在本地不测边界,一提交就被极端数据打脸,这个习惯建议尽早养成。
4. 从一道题看一类题:二分答案模型与周赛430体验
4.1 把“二分答案”抽象成通用模板
吃香蕉这道题做完,我最大的收获不是会做这一题,而是理解了“二分答案”这个更大的模型。你可以把它当成一个通用模板:当题目要求的是“满足某个条件的最小值”或“满足某个条件的最大值”,并且这个条件的成立与否随答案变化呈现单调性时,就可以在答案的可行域上做二分搜索,而不是直接去构造答案。
模板一般长这样:先确定答案的上下界,然后写一个判断函数check(mid),接着在区间里二分逼近最优解。关键在于判断函数能否在可接受的时间内算出来。很多时候check函数的实现比二分本身更难,因为它要求你把题目条件完整地转化为一次可计算的过程。
我拿吃香蕉这题做个映射:答案是速度K,区间是[1, max(piles)],check函数是canEatAll,判断条件是不超过H小时。如果你能把这个映射关系想清楚,那么同样的思路可以直接迁移到另一道经典题“在D天内送达包裹的能力”。那道题只需要把“速度”替换成“每天运货能力”,把“堆香蕉”替换成“连续包裹重量”,把“H小时”替换成“D天”,几乎是一模一样的骨架。
4.2 同类题目延展:二分查找不止能搜索引
很多人对二分的理解局限在“有序数组里找目标值”,比如搜索旋转排序数组、查找某个数的位置,这类题目搜索的是“数组的索引”。但吃香蕉这道题展示的二分对象完全是另一回事,它搜索的是“答案的数值”,而这个数值不一定在某个明确的数组里存在,它只是在一个连续的整数区间里。
这个认知对刷题非常关键,因为LeetCode里有一大批中等题都属于这种“对答案做二分”的套路。除了上面提的“在D天内送达包裹的能力”,还有“分割数组的最大值”“制作m束花所需的最少天数”“第K个最小的质数分数”等,全部可以归入这一类。你一旦掌握了吃香蕉这题的解法,再去做这些题时至少能看出“应该用二分答案”,至少目标明确了一半。
周赛430那阵子,我正好在练这类二分答案题,顺手复盘了一下当周的题目,发现好几题虽然包装复杂,底层还是把二分答案和贪心结合。比如有些题给你的输入不是数组,而是一个可以实时计算某属性的函数,你只能通过调整参数来逼近目标,这种时候二分答案几乎是最稳的解法。如果没建立“对答案二分”的意识,你很可能一上来就想用模拟或者贪心,最后绕进死胡同。
4.3 周赛430的启发:验证思维比刷题量重要
我刷周赛的时候有个很深的感触:很多人题刷了不少,但遇到新题还是会慌。原因在于刷题时只记题型,不记推导过程。吃香蕉这题如果只是背下“用二分”,下次遇到“把一堆任务分成若干组求最小最大时间”类的题,照样不知道怎么下手。
周赛430里的题目让我意识到,真正有用的能力是“快速判断一道题能不能二分”。我在脑子里过了这么几个问题:答案是不是一个值?这个值的范围是不是可以确定?随着值变大,目标结果是不是单调变化?如果三个答案都是“是”,那这道题八成就是二分答案。这个思维模型我在吃香蕉这题上反复验证过,之后遇到类似题目基本不会再走弯路。
另外,周赛和日常刷题有个区别:周赛有时间压力,你必须在半小时内完成读题、建模、编码、调试。所以平时刷题时要刻意训练自己“先写check函数再写二分主逻辑”的节奏。check函数一般是整道题的核心,写清楚了,二分主体只是套模板而已。
5. 常见问题与避坑指南
5.1 最容易让人卡住的三个细节
我在给身边朋友讲这道题时,发现大家卡住的点高度集中。第一个是“向上取整怎么写”,这一点我在前面已经强调过,用(a + b - 1) / b这种整数运算是最稳的。如果你用math.ceil(a / b),在Python里由于除法默认返回浮点数,a和b一到大数就可能精度丢失,导致结果偏小或者报错。我第一次提交就是因为这个问题翻车,后来彻底改成整数运算,再也没在这个点上浪费过时间。
第二个是“边界为什么是1而不是0”,以及“为什么right是max(piles)”这两个问题连在一起容易混。记住速度一定大于等于1,而且超过max(piles)后时间不会再减少,所以右边界就是max(piles)。这不是一个玄学结论,而是可以从总耗时的计算式里推出来的。
第三个是“二分的退出条件到底是什么”。我用的是while left < right,最终left和right相遇时就是答案。如果遇到死循环或者结果偏1的问题,大概率是因为更新left和right时没想清楚“mid是否可能是答案”。建议你在更新右边界时用right = mid,因为mid可能就是最优解,不能把它排除;更新左边界时用left = mid + 1,因为mid已经确认不可行,不用再考虑它。
5.2 调试技巧:把每一步的mid和耗时打出来
如果你在本地测试时发现结果不对,最快的排查方式是打印每次二分时的mid值和对应的总耗时。尤其是当返回的答案比预期小1或大1时,打日志能立刻看出是边界缩小策略出了问题还是canEatAll的计算逻辑出了问题。
我就曾经遇到过一个案例:把hours += (pile + speed - 1) // speed写成了hours += pile // speed + 1,结果当pile刚好能被speed整除时,会多算1小时。这种错误在单测里可能完全看不出来,只有用特定数据比如piles=[2, 2], h=4时才会暴露,因为我期望答案是1,实际却跑出2。打印mid的过程能帮你迅速定位是“判断条件”的问题,而不是“二分方向”的问题。
还有个小技巧:先把暴力解法写出来,然后用随机数据对比二分解法和暴力解法。这种对拍方式在竞赛圈很常用,放在刷题里也极其好用。只要随机生成一堆测试用例,两边输出不一致,就说明二分逻辑有bug。这个方法能帮你守住底线,不至于在一个隐蔽的错误上纠结半天。
5.3 性能与提交:如何在LeetCode上稳过
这道题的输入规模决定了O(N log M)是完全可以接受的。实际测试里,Python版本的运行时间通常在150ms左右,C++版本更是在20ms以内,几乎不会出现性能瓶颈。如果你跑出来特别慢,先检查两处:一是有没有在循环里重复计算max(piles),二是canEatAll里有没用提前退出的机会。前者是常数优化,后者在数据量大的时候能省下不少时间。
提交前记得把代码里的日志全部删掉,或者在本地调试版本和提交版本之间做一个隔离。LeetCode的判题环境对输出很敏感,任何多余打印都会导致Presentation Error或者无谓的时间损耗。我见过不少人在本地测试通过,一提交就各种问题,最后发现是print语句忘记删了。
5.4 常见问题速查表
为了方便你保存和回顾,我把这道题最常遇到的坑整理成了一张表,你可以直接对照排查自己代码里可能出现的问题。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 除零错误 | left初始化成了0,mid变成0 | left初始化为1 |
| 结果比正确答案大1 | 右边界更新为right = mid - 1,把正确答案排除了 | 改为right = mid |
| 结果比正确答案小1 | 时间计算里ceil写错了,可整除时多算1小时 | 用(pile + speed - 1) // speed |
| 运行超时 | 暴力枚举K,或者canEatAll里没有提前退出 | 改用二分,加上hours>h的提前判断 |
| 浮点数精度出错 | 用了math.ceil(pile / speed) | 全部改为整数运算 |
| 返回结果超出int范围 | C++里用int做乘法或加法 | 使用long long做中间计算 |
6. 从073到更多:这个系列后面会聊什么
这篇作为“Leetcode(1/100)”系列的开篇,我特意选了一道中等难度的二分查找题来打底。因为我觉得刷题打卡最重要的不是第一天就冲刺难题,而是通过一道题把一个高频考点彻底吃透。接下来这100题里,我计划按专题继续整理:数组哈希、双指针、滑动窗口、链表、树、回溯、动态规划这几个大方向都会覆盖到。
每一篇我都会尽量按照今天这样的格式来写:先讲清楚为什么选这道题,再拆解思路和边界,然后贴完整可运行的代码,最后写踩坑记录和同类题扩展。比起单纯贴一个AC代码,我更希望能帮你建立一套可复用的思考框架,这样哪怕换一道新题,你也能举一反三。
吃香蕉这道题本身还有个有意思的变体:如果狒狒可以提前在某个时刻休息R小时,问最小速度是多少;或者把“一堆只能一小时一吃”改成“可以同时吃多堆”,模型就会从二分答案变成别的算法。这种变体思路挺适合用来检验自己到底有没有真懂原题。我的建议是,如果你做完这题还有余力,可以先不用看题解,自己改改题目条件,试着推导一下新解法。
回到开头那个问题:为什么我的100题计划第一题选它?因为我始终相信,刷题的意义不在于题量堆积,而在于每题都能带走一个能迁移的思维模型。二分答案这个模型,值得作为整个系列的起点。下一道题我计划聊聊“两数之和”的哈希表思路,当然也可能临时换题,毕竟刷题这事还是跟着感觉走更开心。