1. 从一道二分查找题聊聊我的刷题节奏
day73(2.1)——leetcode面试经典150,这个标题我自己回看都觉得像某种密码,其实就是一个最普通的刷题打卡记录:第73天,2月第1题,用的题单是LeetCode官方整理的面试经典150。熟悉我的朋友都知道,我很少追着热门题单满屏跑,反而更愿意在经典题上反复磨,因为面试考的不是你会不会背题解,而是你面对一道从没见过的题时,能不能把见过的模型套上去。
今天刷到的是二分查找专题里的经典题:爱吃香蕉的狒狒,LeetCode 875题。很多人搜“073爱吃香蕉的狒狒”,其实就是把打卡天数当成题号搜错了位置,这道题的真实编号是875,中文站通常叫“爱吃香蕉的狒狒”,英文站叫Koko Eating Bananas。它属于面试经典150里二分查找部分的必刷题,也是我眼里最典型的“二分答案”模型题——题目表面上是一个模拟过程,实际上考察的是你有没有能力把“最优化问题”转化成“判定问题”,再用二分去逼近答案。
这篇不是单纯贴一份能AC的代码,我想把这道题从读题、建模、边界处理、面试表达,到它背后的一整类题型,都掰开揉碎讲清楚。如果你正在准备春招、秋招或者社招跳槽,刷了几天题总觉得原地踏步,那这篇文章应该能帮你省下不少时间。
先交代一下我自己的刷题进度,方便你对照参考。我在第1到第30天左右,主攻线性表、哈希表、字符串这些基础结构;第31到第60天,集中啃树、图、搜索和动态规划模板;从第61天开始进入二轮强化,每天不再追求“刷了多少道”,而是要求自己“讲得清楚一道题的来龙去脉”。day73正好落在二轮的二分查找专项上,2.1的意思就是2月第一篇复盘日志,恰好用它来做二分答案模型的突破口。
2. 为什么选面试经典150而不是无脑刷热门100
2.1 经典150与热门100的核心差异
很多人打开LeetCode就开始做热门100,这个思路不能说错,但如果你认真盯着榜单看,会发现热门100更像是“全网点击量最高的题”,覆盖面广、难度跨度大,适合用来查漏补缺或者临阵磨枪。而面试经典150是官方按数据结构和算法专题组织的一套题单,每个专题下面都有若干道代表性题目,从链表、二叉树、图论到二分、滑动窗口、动态规划,基本能把你面试中大概率遇到的套路都过一遍。
我自己在day60之前几乎只刷经典150,到了冲刺阶段才用热门100做随机抽练。原因很简单:经典150像一本按章节排好的教材,热门100像一本乱序词表。教材用来搭建知识框架,词表用来最后自测语感,两者不是互相替代关系。面试经典150和热门100之间大概有三成题目重叠,但这不影响你的任何选择。至于周赛题,我一般只在周日练手感,平时不会把周赛难题当作主刷题单,原因后面细说。
2.2 73天下来我实际执行的刷题节奏
如果你也打算用两个月左右刷完这150道题,我建议你把计划拆成三个周期。第一个周期,按专题过一遍基础题,目标是“看得懂题解、能独立写出模板”,耗时大约30天。第二个周期,开始做跨专题的中等题,同一道题尽量尝试两种以上解法,目的是“把模板用熟”,耗时大约20天。第三个周期,重点做高频难题和错题回顾,并且每隔几天用一场限时模拟来检验速度,耗时15到20天。
day73 这个阶段我在第二周期和第三周期之间。二轮复习的题目不能只是“AC一下就行”,我会在每道题后面补一段自己的讲解稿,假想对面坐着面试官,把算法流程、复杂度、边界条件全部口头讲一遍。很多代码写得出的同学,一到说话就卡壳,就是缺乏这个“出声训练”的环节。刷题打卡如果只记录“做了多少道”而不记录“我踩了什么坑、怎么讲清楚”,那数字增长的意义会大打折扣。
2.3 打卡记录应该记什么才不白费
我的每日复盘模板分为四块:核心考点、解法流程、边界条件、面试延伸。以今天这道875为例,核心考点是二分答案;解法流程是“确定搜索区间,判断当前速度是否能在H小时内吃完”;边界条件是左边界从1开始、右边界取堆的最大值、判断时向上取整;面试延伸则是“单调性如何证明”“如果H小于堆数量怎么办”。这四块内容比代码本身重要得多。
很多人的笔记只是把题解复制一遍,这没有意义。真正有价值的记录是你自己当时的错误思路:比如我第一次做的时候就想着直接模拟,从速度1开始慢慢加到能吃完为止,结果在测试用例上跑了超时才意识到要二分。这个“为什么没想到二分”的过程,比最终代码更值得写进打卡里。
3. 爱吃香蕉的狒狒到底在考什么
3.1 先还原题目,别被故事带偏
原题讲的是狒狒(也有人翻译成珂珂)有一堆香蕉,每堆香蕉数量记录在数组 piles 里,它每小时最多吃一堆,如果这一堆少于它当前的速度 k,那么这一小时只吃这一堆,吃不完也不吃另一堆。现在要求它必须在 H 小时内吃完所有香蕉,问最小的速度 k 是多少。
故事绕来绕去,翻译成程序员能懂的话就是:给定一个数组 piles 和一个总时间限制 H,我们选择一个速度 k,对于数组里每一个元素 p,吃掉它需要 ceil(p / k) 小时,最终总耗时不能超过 H。我们要找满足条件的最小的 k。这里的 ceil 是向上取整,因为哪怕只剩下1根香蕉,也要花一整小时去吃完这一堆,不能去开下一堆。
这个“每小时最多吃一堆”的设定是题目的灵魂,它决定了同样的速度在不同分布下耗时是不同的。比如速度是4时,7根一堆需要2小时而不是1.75小时,因为一小时是离散的。
3.2 暴力模拟为什么不行
最容易想到的解法是枚举速度,从1一直试到数组最大值。对于每个速度,遍历每一堆累加时间,检查是否小于等于 H。这个方法逻辑完全正确,但复杂度是 O(max(piles) * n),如果 piles 里出现 10 的 9 次方级别的数字,枚举就要循环十亿次,配合上万长度的数组必然超时。
这就是大多数二分答案题的共性标志:你要求一个满足条件的最小值,而且这个条件的判定函数是单调的。速度越快,总耗时越短,不可能出现速度更快反而耗时更长的情况。既然判定结果随速度变化呈现“前面不行,后面行”的阶梯状,我们就可以用二分查找快速找到拐点位置。
3.3 单调性才是二分的入场券
为什么说速度越快总耗时一定不会增加?因为对任意一堆 p 来说,k1 大于 k2 时,ceil(p / k1) 一定小于等于 ceil(p / k2)。把数组里所有堆的时间加起来,不等关系仍然保持。所以总耗时是关于 k 的单调不增函数。
在我们关心的区间内,一定存在一个最小的 k0,使耗时小于等于 H,而所有比 k0 小的 k 都做不到。这就是一个标准的最小型可行解问题。用二分思路就是:取中点 mid,如果 mid 这个速度能在 H 小时内吃完,说明答案可能还在左边,把右边界收紧到 mid;如果 mid 速度不行,说明答案一定在右边,把左边界收紧到 mid+1。最后 left 就是答案。
3.4 左右边界的取值到底怎么定
左边界取1,因为速度至少是每小时1根。右边界取 max(piles),而不是数组总和。为什么?因为当速度大于等于单堆最大数量后,每一堆都只需要1小时,总耗时就是数组长度,再增大速度不会让总耗时继续减少。既然更大速度对结果没有优化意义,最优解就不可能超过 max(piles)。这一步很多人写错,右边界直接取 sum(piles),虽然结果有时也能过,但会白白扩大二分范围,而且思路解释起来不够干净。
还有一个细节,如果 H 小于数组长度,说明就算每小时吃掉一堆也来不及完成,直接不可能。但二分代码其实能自动处理:即使右边界取 max(piles),判定函数也会返回 false,最终 left 会跑到 max(piles)+1。不过为了代码语义更清晰,可以在一开始加一句判断,避免无意义的二分。
4. 手写代码与运行实例
4.1 Python 版本与逐行解释
import math from typing import List class Solution: def minEatingSpeed(self, piles: List[int], h: int) -> int: # 特别情况:香蕉堆数比小时数还多,必不可能 if len(piles) > h: # 虽然二分也能处理,但提前判断可以让逻辑更清晰 # 这里不直接返回,因为后面会自然收敛,但写上无妨 pass def can_finish(k: int) -> bool: total_hours = 0 for p in piles: total_hours += (p + k - 1) // k return total_hours <= h left = 1 right = max(piles) while left < right: mid = (left + right) // 2 if can_finish(mid): right = mid else: left = mid + 1 return left这里最关键的代码是 (p + k - 1) // k,它实现了向上取整。很多新手写成 int(math.ceil(p / k)),功能没错,但浮点数运算在大数场景下可能有精度损耗,而且调用库函数有额外开销。用整数运算表达向上取整,是面试中更稳妥的习惯,也是一个值得记住的模板公式。
while 循环用的是左闭右开变体,即 left 是可行答案的下界,right 是当前发现可行上界,循环结束时 left 等于 right。注意当 can_finish(mid) 为真时,mid 本身可能是答案,所以 right = mid 而不是 mid - 1;当 can_finish(mid) 为假时,答案至少是 mid + 1,所以 left = mid + 1。这样写可以完全避免死循环。
4.2 一个具体数组的二分推演
我们拿经典测试用例 [3, 6, 7, 11],H = 8 来手动走一遍,确保你理解整个搜索过程。数组最大值是 11,所以 left = 1,right = 11。
第一次二分,mid = 6。速度6时,第一堆耗时 ceil(3/6)=1,第二堆 ceil(6/6)=1,第三堆 ceil(7/6)=2,第四堆 ceil(11/6)=2,总耗时6小时,小于等于8,说明速度6可行,right 更新为6。
第二次二分,区间变成 [1,6],mid = 3。速度3时,四堆耗时分别为1、2、3、4,总耗时10小时,大于8,说明速度3不可行,left 更新为4。
第三次二分,区间变成 [4,6],mid = 5。速度5时,四堆耗时分别为1、2、2、3,总耗时8小时,等于8,可行,right 更新为5。
第四次二分,区间变成 [4,5],mid = 4。速度4时,四堆耗时分别为1、2、2、3,总耗时8小时,等于8,可行,right 更新为4。
此时 left 等于 right 等于4,循环结束,返回4。这个推演过程建议你在白板上也画一遍,面试时和面试官讲题,不用画得像图解那样精美,但一定要能口算出每一步,这会显得你是真的理解而不是背题。
4.3 复杂度分析
二分外层循环执行 O(log(max(piles))) 次,每次调用判定函数都遍历一遍数组,所以时间复杂度是 O(n log max(piles)),n 是数组长度。空间复杂度是 O(1),只用了几个临时变量。
对比暴力枚举 O(max(piles) * n),在 max(piles) 很大的情况下,二分优化是指数级的。很多数据规模大的题目,核心考点就是要你识别出这个二分答案结构,而不是真的考察你怎么又快又准地计算向上取整。
4.4 同模型的题目还能套到哪里
875 题是二分答案模型里最好讲的一道,因为它故事简单、判定函数清晰。同模型的题刷多了你会发现套路完全一样,换的只是“判定函数”的内容。比如 LeetCode 1011 在 D 天内送达包裹的能力,给你一个包裹重量数组,求最小载重能力,判定函数就是按顺序装包裹需要的天数是否超过D。还有 LeetCode 410 分割数组的最大值,要最大化最小值,本质上也是二分答案。
LeetCode 1482 制作 m 束花所需的最少天数,以及 LeetCode 2064 最大化一台游戏机的最小分数,都是类似模型。面试经典150里这类题并不少,你把875彻底吃透,再去做其他二分答案题,基本就是把判定函数换一换的事。
我自己刷题有个习惯:每做完一道题,就去题解区找同模型题再做一道,形成“一串题”而不是“一道题”。如果你能在三天内连续做4到5道二分答案题,你会发现识别题型的神经反射会明显变快,这比隔三差五做一道效果强得多。
5. 面试官真正想听到的表达方式
5.1 先证明单调性,再提出二分
很多候选人一上来就写二分代码,面试官问他为什么能用二分,他说因为数据量大。这个回答只能拿及格分。正确的表达顺序应该是:先明确指出这个问题是一个最优化问题,求最小速度;然后说明这个最优化问题存在单调性,速度越快总耗时越短;最后才提出用二分把最优化问题变成判定问题。
单调性的证明不需要写得像数学论文。你可以说,对任意一堆香蕉,速度从 k 变成 k+1,吃完这一堆所需时间一定不会增加,因此总耗时一定不会增加。这其实就是一个反证法的直觉化版本,面试官通常点个头就过了。
5.2 边界条件是你和面试官拉开差距的地方
边界条件往往是面试官最喜欢追问的点。常见的边界包括:左边界为什么是1而不是0;右边界为什么是 max(piles) 而不是 sum(piles);向上取整为什么用 (p + k - 1) // k;二分循环里等号应该加在哪边。
还有一个隐藏边界是:如果 H 恰好等于数组长度,答案就是 max(piles),因为这时候必须每堆都花一小时。如果 H 小于数组长度,任何速度都来不及,这时可以直接返回一个无意义值或者明确说明无解。虽然 LeetCode 原题保证有解,但面试官可能会改编成“如果无解怎么办”来考你,思考一下总没坏处。
5.3 一个现场追问:如何验证你的代码没有死循环
面试官可能会问,mid = (left + right) // 2 会不会死循环。这个问题在二分答案模板里很容易答:当 left + 1 等于 right 时,mid 等于 left,如果 can_finish(left) 为真,right 变成 left,循环结束;如果为假,left 变成 left+1,也就是 right,循环也结束。因为 two分支都会让区间缩小,所以不会死循环。
如果你用 left + (right - left) // 2 的方式写,还可以顺带提一句这是为了防止 left + right 溢出。Python 整数无上限不需要担心,但 C++ 或 Java 里可能会被追问,准备一个安全的写法总是加分项。
5.4 与热门100和周赛题的联动思考
热门100题里也有几道二分查找题,比如搜索旋转排序数组、在排序数组中查找元素的第一个和最后一个位置,它们考的是“在一个有序或半有序数组中查找目标值”。875 考的是“在答案域上二分”。两类题型如果用一句话区分,就是前者搜索空间是数组下标,后者搜索空间是数值范围。
周赛题偶尔也会出现二分答案,但周赛经常把题目包装得比较复杂,比如加一个很难的判定函数或者前置贪心。做周赛题更大的价值是练习读题速度和抗压能力,而不是学习新模板。拿周赛430的某些题来说,它的难度可能远超经典150的平均水平,我个人的策略是:经典150打底,周赛只做自己能力范围内能稳定AC的题,不在偏门上消耗精力。
6. 常见问题与踩坑实录
6.1 本地跑得好好的,一提交就超时
我见过很多人在875上超时,原因几乎都出在判定函数里多做了没必要的事。比如判定函数里每次都对 piles 排序,或者用了 while 循环一层层减去 k 来模拟一小时一小时地吃,这些写法在功能上没错,但复杂度会退化。
模拟吃的正确姿势是直接用除法向上取整,而不是循环减。一小时一小时地减,对于数量为 10 的 9 次方的香蕉堆来说,即便速度也很大,循环次数也很可观。判定函数需要被调用 log 次,如果判定函数内部再套一个 n 复杂度操作,整个时间复杂度就会从 O(n log max) 变成 O(n log max) 的常数倍或者更差。
6.2 向上取整的精度坑
浮点数向上取整有一个经典问题:在极限情况下,大整数转成 float 可能丢失精度,导致 ceil 结果差1。例如某些语言里浮点数精度只有 53 位二进制,当 p 超过 2 的 53 次方时,p / k 的浮点表示不再精确。
最稳妥的做法永远是用整数运算实现向上取整:ceil(p / k) = (p + k - 1) // k。这个公式在任何语言里都成立,也不依赖浮点精度。刷 LeetCode 倒很少碰到这么大的数,但养成这个习惯能帮你避开不少隐藏雷区。
6.3 判断条件等号应该放在哪边
二分查找里等号归属是最大陷阱之一。875 的判定条件是 total_hours <= h,也就是说总耗时等于限制时间也算可行,等号要算在“可行”这一边。如果你写成 total_hours < h,会漏掉正好在边界上的答案,最终结果偏大。
反过来,有些题目问你“最大可行值”,等号归属逻辑可能又不同。判断等号归属没有万能口诀,唯一方法是每次写代码时明确自己想找的是“最大可行”还是“最小可行”,然后推演一遍样例。我一般会在代码旁边注释一行“可行 = 耗时 <= 限制”,提醒自己别把条件写反。
6.4 死循环和左右边界混淆
当你已经用二分做了几道题,突然某道题卡在死循环里,很大概率是你把左闭右闭和左闭右开混用了。875 这个模板里,left 始终是候选答案,right 始终是已知或潜在可行上界,循环不变量必须保持一致。
如果 while left < right 但内部出现了 right = mid - 1,就可能漏掉 mid 本身。对于最小可行解问题,mid 可行时应该保守地把 right 收缩到 mid,而不是跳到 mid-1。同理,mid 不可行时 left 可以果断跳到 mid+1,因为 mid 已经被证明不可能是答案。这套逻辑推明白之后,二分答案基本不会再死循环。
6.5 刷到第73天如何对抗倦怠期
说句实在话,刷题刷到第70天左右是最容易放弃的阶段。基础题刷腻了,难题又啃不动,每天打开编辑器都不知道该干嘛。我的应对策略是降低“新题”数量,把当天大部分时间用在重写旧题上。比如875这道题,我在三周前就AC过,但今天重新写,要求自己不看任何提示,边写边讲,讲完再翻上次的打卡笔记对比差异。
这个过程很枯燥,但效果显著。重写旧题会暴露出很多“好像懂了其实没懂”的细节,比如右边界为什么是 max(piles),这个点在第一遍刷的时候我根本没真正理解,直到二刷要开口讲才发现解释不清楚。打卡的意义不是每天发一个“今日第X天”的仪式感,而是逼着自己在某个阶段把“我会做”变成“我能讲清楚”。
6.6 和周赛、热榜内容怎么平衡
如果你关注过最新热词,可能会看到“leetcode周赛430”这类内容频繁出现。我的态度是,周赛可以参加,但不要让它主导你的刷题计划。周赛题通常更追求新颖的组合和更复杂的优化,而面试经典150覆盖的是更本质的模型。今天做875,明天去看看周赛里有没有用二分答案的新题,有就顺手记一下,没有也没关系,这不影响主线进度。
还有一个常被忽略的问题:面试官很少会直接考周赛第四题的难度,却很可能拿875这种题进行变形追问。所以与其花三小时死磕一道周赛压轴题,不如把时间花在吃透经典150的二三题级别题目上,性价比会高很多。
7. 一些我踩过之后才明白的小技巧
最后分享几个只靠刷题很难总结出来的细节。第一,二分答案的代码模板一定要背到几乎形成肌肉记忆,这样到了面试高压环境下才能顺手写对,不用现场推理左右边界。第二,判定函数单独抽出来写,不要和二分主体混在一起,这样不仅代码结构清晰,面试时也方便单独讲判定函数的设计思路。第三,写完代码不要急着提交,先在脑内选一到两个极端用例跑一遍,比如全部香蕉在一堆里、H等于数组长度、piles全是同一个数,这能瞬间暴露边界问题。
我在实际使用中发现,很多人做这类题最大的障碍不是不懂二分,而是不会判断“这个题能不能用二分”。判断方法其实很简单:题目问的是最小或最大值,并且这个值的可行性与某个参数的单调性相关,那八九不离十就是二分答案。一旦识别出这个模型,剩下的工作就是设计判定函数。
这道875我刷了不止一遍,每次都会发现新的理解角度,第一次是背模板,第二次是搞懂右边界,第三次是能向别人讲清楚单调性证明。LeetCode面试经典150之所以值得慢慢刷,也是因为里面的题目经得起反复咀嚼。下次如果你也刷到第73天左右开始疲惫,不妨挑一道刷过的简单题,闭卷重写一遍,再对着镜子讲一遍,你会感受到一种比做新题更踏实的进步。