拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从20ms到0ms:LeetCode 875题二分查找性能优化复盘

从20ms到0ms:LeetCode 875题二分查找性能优化复盘

今天打开LeetCode,把昨天磨了一晚上的875题提交,页面弹出来“0 ms,faster than 100.00%”。说实话,这类截图在刷题群里天天见,可当它出现在自己的提交记录上时,我还是忍不住想认真复盘一遍。这篇小记会把这轮“从能通过到跑进1ms”的过程完整拆开:先聊聊0ms和击败100%的实际含金量,再回到875这道题本身怎么解,然后是我落地的四刀优化,最后是跑出成绩之后的验证和复盘。无论你是刚开始刷题的小白,还是正在冲刺极端性能的老手,都能在里面找到一点能拿走的东西。

1. 水落石出:LeetCode的0ms到底有多少含金量

1.1 计时精度只有1ms,0ms更准确的名字是“查无此秒”

LeetCode对运行时间的统计精度是1毫秒。显示的0ms并不是真的“零耗时”,而是“这次执行时间小于1毫秒,四舍五入归零了”。本质上,0ms是一种查询不到具体耗时的状态,我管它叫“查无此秒”。这不意味着代码被优化到了极限,只说明它的常数已经小到连计时器都懒得记录。

这个坑我早年踩过。第一次在Easy题上拿到0ms,兴奋地跟室友吹了半天,后来发现那道题哪怕用两层循环也能0ms——测试数据太小,任何正常写法的耗时就都落在1ms刻度以内,根本没法用这个指标区分好坏。

我翻过自己的提交记录,0ms出现频率最高的是两类:一类是字符串拼接、数组翻转这种送分题,另一类就是今天这种规模到位的中等或Hard题。前者的0ms是数据规模给的,后者的0ms才是解法该抢的。

1.2 击败100%的百分比,本身就是个浮动的统计量

那“faster than 100.00%”是不是代表我把所有提交者都踩在脚下了?也不是。这个数据本质上是一个样本统计量,基于的是评测服务器积累的同语言历史提交。

问题在于样本会变,服务器状态也在变。同样的代码,我今天提交是0ms,晚上再去交一次也许就是4ms;同一份代码在不同人手里,也可能一个人看到击败87%,另一个人看到击败100%。我做过最无聊的验证:把一份稳定通过的代码连交五次,结果显示0ms、0ms、4ms、8ms、4ms,击败比例从100%到73%都出现过。所以百分比更像是给你一个“当前这波样本里的位置感”,跟绝对性能没有严谨的数学对应。

另外,百分比还受近期提交者水平分布的影响。如果一批人扎堆用更优解法去交,同一个代码的百分位自然会被推到后面。这不代表你变弱了,只代表样本变化了。

1.3 什么样的0ms才真正值得庆祝

所以0ms到底可不可信?我的判断标准很简单:题不是送分Easy,解法本身有复杂度上的优势,而且不是一次性,重复提交依然稳定。三项都满足,这个0ms才值得截图、值得复盘、值得写一篇小记。

875这道题勉强满足前两条,所以我决定把这次“能通过”到“跑进1ms”的过程完整拆开。整个复盘包括题面分析、四刀优化和跑出成绩之后的验证,每个环节都有可复用的经验。

2. 今天的主角:LeetCode 875 爱吃香蕉的狒狒

2.1 题面回顾:Koko一小时只能专心吃一堆

这道题在力扣中文站的题名是《爱吃香蕉的狒狒》,题号875。题目给了n堆香蕉,piles[i]表示第i堆有多少根。狒狒Koko每小时能吃k根香蕉,但有个要命的限制:它一个小时只能对着一堆吃,哪怕这一堆只剩一根,剩下的时间也不会转场去别的堆。

所以“每小时吃k根”准确的理解是“最多吃k根,且限制消耗对象是某一堆”。判断速度k是否可行,需要对每一堆计算它需要的小时数:ceil(piles[i] / k)。把所有堆的小时数加起来,如果不超过总时长h,就可行。

题目还有一个隐含前提:h一定大于等于堆数n。因为哪怕速度开到无穷大,一小时最多也只能消灭一堆,h小于n的时候无论如何无解。这个前提保证了二分上界可以安心取max(piles),不会出现无解的情况。

我第一次读完题面,脑子里冒出来的是最朴素的枚举法:k从1开始逐个试,每试一个就遍历全部堆累加小时数,第一个满足条件的k就是答案。逻辑无懈可击,但k的取值上限是max(piles),最大能到10^9,再叠加上n最大10^4,完全撑不住。

2.2 单调性是这道题的破题钥匙

这题真正的突破口是单调性。速度k越大,吃完所有香蕉所需的总时间只会更短,绝不会更长。于是“在h小时内能否完成”这个判断结果,随着k从1增大,会由false变成true,而且只变一次。面对这种单调可分割的搜索区间,二分查找就是标准答案。

搜索范围不用乱想,就是[1, max(piles)]。k超过max(piles)没有意义,因为速度再大,每堆也至少需要一小时,k=max(piles)时总时间已经是最小值n小时了。

判断某个速度m是否可行的过程里,最需要注意的是不要引入浮点数。每堆耗时ceil(piles[i] / m),如果写成(double)piles[i] / m然后向上取整,会有精度和性能双重损耗。正确姿势是整数运算:(piles[i] + m - 1) / m。这个式子的原理是给被除数补上m-1,让除法结果自动向上取整。类比来说,就像“把一个数往最近的m的倍数方向凑”,补到能整除为止。

为了验证边界逻辑,我手算了一组示例:piles = [3, 6, 7, 11],h = 8。速度4时,四堆耗时分别是1、2、2、3小时,总共8小时,可行;速度3时,四堆耗时是1、2、3、4小时,总共10小时,不可行。答案就是4。这个例子能很好地检验check函数有没有写偏。

另外聊一下二分模板的选择。网上二分写法五花八门,左闭右闭、左闭右开、找左边界、找右边界,第一次接触的人很容易绕晕。我个人的习惯是统一用[left, right]左闭右闭,配合while (left < right),收敛时返回left。判断mid可行时,因为mid本身可能是答案,所以right = mid;不可行时,mid被严格排除,所以left = mid + 1。这种写法最大程度避免死循环,也适合875这种找最小可行值的场景。如果你习惯左闭右开,那是另外一整套边界逻辑,千万别混着写。

2.3 朴素二分能跑到什么水平

我第一版代码就是标准的“二分答案 + check”,写完一把过,成绩大概是20ms左右,击败60%。这个成绩和“0ms”之间还有明显距离。于是我开始逐项审视这20ms花在哪儿了。

二分本身最多迭代约30轮,每轮遍历n=10^4,大约30万次核心运算。在不算离谱的常数下,C++跑这个量级应该是几毫秒到十几毫秒。所以20ms说明常数还有水分:iostream同步开销、check函数无脑遍历、类型和运算的选择,都在拖后腿。下面这四刀,每一刀单独拿出来都不算什么高深技巧,叠加起来的效果却足够把结果顶到0ms。

3. 从20ms到0ms:我实际做的四刀优化

3.1 第一刀:断掉C++标准流和C语言流之间的同步

先说说C++选手最容易忽略的隐藏开销。cin和cout默认会跟C语言的scanf和printf保持同步,保证两种IO混用时不会错乱。这个同步机制在刷题场景毫无必要——我根本不会在同一段代码里同时用两套IO风格,但它会拖慢每次cin/cout的执行。

关掉它的办法是两句话:

ios::sync_with_stdio(false); cin.tie(nullptr);

sync_with_stdio(false)让iostream不再和stdio做同步校验,cin.tie(nullptr)则解除了cin和cout之间的关联。默认情况下,cin和cout是绑在一起的,每次用cin读数据前,系统都会担心cout缓冲区里的旧内容还没输出,会先去刷新cout,这个连锁刷新非常费时。解绑之后,读归读,写归写,输出量小的时候完全不冲突。

我习惯用一个技巧在进入main之前就把这两步做完:

static const bool io_sync_off = []() { ios::sync_with_stdio(false); cin.tie(nullptr); return true; }();

这个写法利用静态变量的初始化时机,匿名lambda在main执行之前运行,返回true只是为了让编译器不报“未使用变量”的警告。加了这一刀之后,875的成绩大概从20ms降到了12ms左右。

3.2 第二刀:用理论下界压缩二分区间

二分查找的总轮数取决于区间长度。默认情况下,搜索范围是[1, max(piles)],max(piles)最大能到10^9,log2(10^9)大约是30轮。每少一轮,相当于直接省掉完整的某轮check循环。

怎么把下界往上抬?我利用的是一道朴素的总量逻辑:所有堆的香蕉总数是sum,总时长只有h小时,那么无论如何,平均每小时至少得吃掉sum/h根香蕉。考虑到小时粒度是整数、最后一小时可能吃不满,更严谨的下界是ceil(sum / h),也就是(sum + h - 1) / h。

如果某个k小于这个下界,即使狒狒每一小时都满载运行,总吞吐量也满足不了需求,它完全没有可能成为答案。把这个下界塞进二分的left初始值,区间长度被压缩,迭代次数自然减掉几轮。极端情况下,当数据里有一堆特别大时,下界甚至会直接逼近上界,二分几乎原地收敛。

上面的示例piles = [3, 6, 7, 11],h = 8,sum = 27,ceil(27/8) = 4,下界恰好就是答案。也就是说,这道示例甚至不用二分,直接返回下界就是对的。这跟之前推导的结果一致,说明这个下界逻辑相当可靠。

这一步之后,成绩大概从12ms到了8ms附近。

3.3 第三刀:check函数里的提前终止

二分的主体是check函数:给定速度m,累加每一堆需要的小时数,和h比较。正统写法是老老实实把数组遍历完再返回,但这里有一个很容易被忽略的观察:我真正需要知道的只有“总小时数是否已经超过h”。一旦累计值越过h,后面所有堆都不用再算了,直接返回false。

这个剪枝在二分过程中非常有用,因为二分有一半左右的mid会落在不可行区域,而这些区域里累计小时数往往很快超过h。比如速度过小的时候,前几堆的耗时就是天文数字,几轮加法就要跳出。加上break之后,检查逻辑从“全量计算”变成“最多算到超时为止”,实际遍历的堆数远小于n。

对应结构像这样:

long long hours = 0; for (int bananas : piles) { hours += (bananas + mid - 1) / mid; if (hours > h) break; }

我顺手把check判断内联进了循环,省掉高频调用点的函数栈帧开销。对于这种每轮二分都要触发几十万次的调用点,函数调用本身虽然不大,但积少成多也值得消掉。这一刀加完,成绩已经在4ms左右了。

3.4 第四刀:类型选对了,数学才不会悄悄变质

最后一刀看着不起眼,但能决定你从4ms到0ms之间的路是否走得稳。题目数据范围里,piles[i]最大10^9,n最大10^4,sum极限接近10^13,int根本装不下。如果不小心把sum、hours这类变量定义成int,在大数据用例下会出现溢出后的负数,check函数直接给出错误判定。

二分里还有两个常见的类型细节。第一,中间值要写成left + (right - left) / 2而不是(left + right) / 2,前者能防止两数相加时溢出。第二,check内部的表达式(bananas + mid - 1) / mid,如果bananas是int而mid是long long,加法时int会自动提升为long long,通常是安全的;但如果mid被定义成int,那么大数据下bananas + mid - 1本身可能溢出,结果不可信。所以我全程用long long,只有最后返回答案时才转回int。

另外一个看似不起眼但值得记住的选择:right取max(piles)而不是sum。速度超过max(piles)后,总时间已经降到最小可能值n小时,再往上提速没有任何收益,只会白白增加二分轮数。

类型修正本身不直接制造0ms,但它保证了极端用例下代码不会悄悄算错。没有这层保证,前面三刀优化得再狠,也只是在错误的大厦上跳踢踏舞。

3.5 最终代码与提交成绩

四刀全部落地,完整代码如下:

static const bool io_sync_off = []() { ios::sync_with_stdio(false); cin.tie(nullptr); return true; }(); class Solution { public: int minEatingSpeed(vector<int>& piles, int h) { long long sum = 0; long long maxPiles = 0; for (int bananas : piles) { sum += bananas; if (bananas > maxPiles) maxPiles = bananas; } long long left = (sum + h - 1) / h; long long right = maxPiles; while (left < right) { long long mid = left + (right - left) / 2; long long hours = 0; for (int bananas : piles) { hours += (bananas + mid - 1) / mid; if (hours > h) break; } if (hours <= h) { right = mid; } else { left = mid + 1; } } return (int)left; } };

提交之后,页面直接弹0ms,击败100%。我为了排除服务器运气,又连交两次,还是0ms。整个代码的时间复杂度是O(n log M),M是最大堆香蕉数,空间复杂度O(1)。0ms的本质不是算法级别的飞跃,而是把所有常数项都压到足够小,小到运行时间跨不过1毫秒的刻度线。

我把这条优化链路的典型耗时整理了一下,方便你对照自己的实测情况:

优化阶段大致耗时主要收益
朴素二分20ms正确性优先
关闭IO同步12ms输入输出常数大幅下降
理论下界压缩8ms二分迭代轮数减少
check提前终止4ms不可行区间快速短路
类型修正+最终版本0ms稳定性保障,跨过刻度线

这些数字是我多次提交里挑的典型值,不是精确实验数据。评测机的负载波动会带来1到2ms的浮动,所以看趋势就好,别把具体数值当成硬指标。

4. 跑出0ms之后,我反而会做这几件事

4.1 用极端用例先捶一遍,验证的不是性能而是正确性

0ms的成绩容易让人瞬间自信,但二分题的边界错误也最擅长在这些时刻偷袭。拿到0ms后我的第一反应不是庆祝,而是把几组可能击穿边界的用例跑一遍。

测试用例期望答案代码返回
piles = [3,6,7,11], h = 844
piles = [30,11,23,4,20], h = 53030
piles = [30,11,23,4,20], h = 62323
全为10^9, n=10^4, h=10^410^910^9

中间两个用例很有意思。h = 5时,堆数也是5,狒狒必须每小时消灭一堆,速度至少要达到最大堆30,答案因此锁定30;h = 6时,速度23会让各堆耗时变成2、1、1、1、1,总耗时6,恰好可行,而速度22则变成2、1、2、1、1,总耗时7,超了。这种灵敏度极高的对照数据,最能暴露二分边界的偏差。

最后那个大数据用例,专门验证sum会不会溢出、运行时间是否仍可控。四个用例全部通过,我才在复盘笔记里给了这次0ms一个“有效”的标签。

4.2 把击败百分比扔掉,回到复杂度本身

0ms好看,但它改变不了这道题的复杂度本质。这题的求解过程注定是“在一个值域上搜索”,而值域上限最大10^9,线性枚举完全不可行,二分迭代到O(log M)层是数学上的铁律。在最优算法框架下,进一步压榨只能靠常数因子,而常数因子决定的是“击败百分比”而不是“复杂度类别”。

我建议每个刷题人都养成一个习惯:提交通过后,把页面上的百分比从心里划掉,问自己三个问题——我的算法是什么复杂度?空间呢?还能不能从算法层面继续降一个量级?875的答案是O(n log M)、O(1)、不能从算法层面再降。这时候百分比的含金量才算有了坐标系。

LeetCode热门题的题解区里,二分答案的写法五花八门,很多人会贴出自己的0ms代码。参考他们怎么调边界、怎么处理向上取整,比抄一份代码有价值得多。尤其是875这种几乎每届刷题人都会做的高频题,题解里的边界讨论几乎包含了二分题所有常见的坑。

4.3 换个语言、换个姿势,再写一遍

0ms是C++的专属浪漫,换个语言体验会完全不同。我为了验证思路的普适性,用Python把同样的二分逻辑写了一遍:

def minEatingSpeed(piles, h): lo, hi = (sum(piles) + h - 1) // h, max(piles) while lo < hi: mid = (lo + hi) // 2 hours = sum((p + mid - 1) // mid for p in piles) if hours <= h: hi = mid else: lo = mid + 1 return lo

同样的逻辑,Python在大数据用例下会跑出几百毫秒甚至可能超时,因为语言解释器的常数开销摆在那里。但这不代表Python解法“更差”,它只是没法靠常数取胜,必须更加依赖算法复杂度本身。所以不同语言之间的击败百分比完全没有可比性,跨语言比较是最没意义的事情之一。

这段Python代码最大的价值是帮我确认:整个优化链条里真正不可或缺的只有二分框架和向上取整公式。C++里的IO优化、内联check、提前break,都是把常数压到极限的“物理加速器”,对思路本身没有任何影响。

4.4 复盘:这次赢在哪里,又有多少运气成分

把四刀摆开复盘,这轮优化真正改变战局的是两个点:输入输出同步关闭和check函数提前退出。前者把基线成本砍掉大半,后者把不可行区间的计算量大幅缩减。理论下界压缩和类型修正属于稳定性保障,它们不直接制造0ms,但能防止你在大数据用例上翻车。

至于从8ms到0ms那一步,我必须诚实:服务器有贡献。评测机负载、提交时段的波动都会影响结果。我有过连着三次0ms的经历,也遇到过在另一道题上同样的写法只跑出4ms的情况。所以我的结论是,0ms值得当一次成就记录,但千万别把它当成衡量代码的唯一标准。

这轮复盘做完,我顺手把875的题解思路整理进了自己的二分模板笔记。每次遇到“求最小可行值”“求最大可行值”“答案域连续单调”这类关键词,就直接套这套流程:写check、定上下界、左闭右闭二分、收敛返回。近期连周赛里好几道题,本质上都是这个模板换了一层皮。

好了,这轮0ms的复盘就到这里。说点题外话,我自己这两年刷题下来最大的体会是:LeetCode的击败百分比就像游戏的段位图标,看着刺激,但真正决定你水平提升的,永远是提交之后敢不敢把代码拆开重来一遍的耐心。如果你也想挑战一次0ms,先从这道爱吃香蕉的狒狒入手,把二分模板练到条件反射,再配上一套属于自己的IO和check函数优化套路。等你真的在某道题上看到0ms的时候,就会明白那种感觉——还真的挺上头的。

返回列表