1. 先把这个“移山”问题翻译成人话
1.1 题目到底在算什么
前两天刷题碰到一道题,名字挺唬人,叫“移山所需的最少秒数”,就是 LeetCode 3296。题目描述其实很简短:给一座山,高度是mountainHeight,再给一组工人workerTimes,第i个工人移除第j单位高度需要workerTimes[i] * j秒。也就是说,每个人搬第一块土用时固定,搬第二块要两倍时间,搬第三块要三倍时间,越往后越慢。问的是所有工人一起开工,最少要多少秒才能把山移平。
我第一次看到这个题,第一反应是“这有什么难的,总高度除以总速度不就完了吗”。等我真正动手推公式才发现,事情完全不是那样。因为每个工人的产出率不是一个常量,而是随着自己已经搬走的块数逐步下降。一个人搬 k 块土的总耗时不是w * k,而是:
T(k) = w * (1 + 2 + ... + k) = w * k * (k + 1) / 2
这是一个二次增长,不是线性的。所以这道题表面是个“调度安排”题,内核其实是“二分答案 + 数学判定”的典型题。
这篇文章不是单纯念一遍题解,我会把自己从读题到写出正确代码的完整思考过程、卡过的边界、以及为什么这个模型在工程里也经常出现,全部讲清楚。如果你在准备算法面试,或者正在刷二分答案类题目,这篇文章应该能帮你少走很多弯路。
1.2 为什么不能直觉地“按速度”除一下
很多人看这个题,第一反应是:每个工人每秒能搬1 / workerTimes[i]的高度,把所有工人的速度加起来,然后用mountainHeight / 总速度不就是答案吗?
这个想法错得挺隐蔽。原因在于工人搬第 j 块土的耗时是变化的,不存在一个固定的“每秒搬多少块”的恒定速度。你只能说他“搬完前 k 块土总共用了多少秒”。如果把时间切成无限小段,每一秒的“瞬时产出”都在变化,根本没法用一个常数速率去算。
更关键的是,多个工人并行时,不同工人的“下一块土耗时”也不一样。比如工人 A 搬下一块要 5 秒,工人 B 搬下一块要 1 秒,那么在同一个时间窗口内,哪个工人应该继续干活,哪个工人应该休息,这种动态分配问题如果直接模拟,会非常麻烦。本质上,我们要求的是把mountainHeight个单位分配给各个工人,使得每个工人自己的累计耗时尽量均衡,并且总体最大耗时最小。
所以这道题不能靠贪心模拟,也不能靠简单除法,得换一种思路。
1.3 换个思路:问自己“T秒够不够”
我后来意识到,这道题真正的突破口是:把“求最少秒数”变成一个“给定秒数,判断是否可行”的问题。
假设现在给你一个候选答案T,问你:如果只给所有工人T秒,他们能不能把整个山移完?这个问题好回答多了。因为每个工人在T秒内的产出上限是确定的,我只需要把每个工人T秒内最多能搬多少块土算出来,加总,然后看总数有没有达到mountainHeight。
而这个“猜一个时间,再验证够不够”的过程,正好就是二分答案。时间越短越难完成,时间越长越容易完成,答案一定落在“不可行”和“可行”的分界点上。于是问题从“怎么安排工人”变成了“怎么快速验证”。
这个思维切换,才是整道题最值钱的地方。
2. 核心解法:二分时间 + 产能判定函数
2.1 单调性是我们敢二分的理由
二分答案能成立的前提是单调性。在这道题里,单调性非常直观:如果T秒能完成任务,那么任何比T更大的时间也一定能完成任务。反过来,如果T秒完不成,更短的时间也完不成。换句话说,可行性随着时间增加而从“否”变成“是”,只会翻转一次。
这个性质有点像猜数字游戏:区间左边是“太小”,区间右边是“够大”,每次取中间值问一次,如果是加大就向左半边找,如果还不够就向右半边找。二分答案的时间复杂度是O(log R),其中R是答案的取值区间长度。哪怕区间长度是10^18,二分六十轮也就结束了,这才是这个方案能扛住大数据的关键。
我用一个生活化的例子帮你理解。假设你要烧一壶水,不知道要几分钟,但你有一个温度计。你先猜 5 分钟,水没开;再猜 10 分钟,水开了。那答案一定在 5 到 10 之间。接下来猜 7 分钟,还没开;猜 8 分钟,刚好开;猜 9 分钟,也开。于是你知道最少需要 8 分钟。这和我们验证“给定 T 秒够不够”是完全一样的逻辑。
2.2 给定T秒,算出每个工人能干多少活
现在关键来了:怎么快速算出一个工人在T秒内最多能搬多少块土?
设这个工人w最多能搬x块土,那么“搬完 x 块土的总耗时”必须不超过T。公式是:
w * x * (x + 1) / 2 <= T
很多人会直接在这里用乘法硬算,但其实有一个非常关键的防溢出技巧:两边同时除以w。因为w是正整数,左边是w * (x * (x + 1) / 2),右边是T,所以原不等式等价于:
x * (x + 1) / 2 <= floor(T / w)
令limit = T / w,剩下的问题就是解一个一元二次不等式:
x^2 + x - 2 * limit <= 0
求根公式给出:
x = floor( (sqrt(1 + 8 * limit) - 1) / 2 )
为什么这里强调T / w而不是T?因为w * x * (x + 1) / 2在w和x都很大的时候,可能超过 64 位整数的范围。而先用T / w,所有中间值都被限制在T这个量级附近,安全得多。
理论上公式算出来的x就是最大可搬块数。但实际写代码时,由于浮点数开方存在精度误差,直接拿公式结果去用,可能在边界上差 1 到 2 个。所以稳妥的做法是:先用公式算一个近似值,再用一两个while循环把边界修到正确。
2.3 check函数的三个防爆细节
完整的check函数要做三件事:第一,对每个工人算出x_i;第二,把所有x_i累加;第三,判断累加结果是否达到mountainHeight。这个过程里有三个细节,我建议你做面试题时也养成习惯。
第一个细节,就是前面提到的“左右两边先除以w”。这个变形不只是防止溢出,还能让公式里的limit位数变小,计算更快。
第二个细节,累加total时,一旦total >= mountainHeight,立刻return true。一方面省时间,另一方面防止total无限累加导致溢出。因为题目只关心“够不够”,不关心“超出多少”。
第三个细节,计算x的时候,初始值可以给一个“偏大”的估计,然后只向下修正。这样while循环的次数是常数级,不会引入额外复杂度。我的习惯是取:
long long x = sqrt(2.0 * limit) + 2;然后向下修正:
while (x * (x + 1) / 2 > limit) x--;如果浮点误差导致初始值比真实值小,那再加一个向上修正:
while ((x + 1) * (x + 2) / 2 <= limit) x++;注意“向下修正”和“向上修正”的顺序,以及循环条件,都要写对。我见过不少同学把这个写成死循环,后面我会专门讲这个坑。
3. 完整代码实现与复杂度分析
3.1 C++ 版本(带注释)
完整的 C++ 解法如下,我加了注释,方便你直接对着看:
class Solution { public: long long minimumSeconds(int mountainHeight, vector<int>& workerTimes) { auto can = [&](long long t) -> bool { long long total = 0; for (int w : workerTimes) { // 关键:两边同时除以 w long long limit = t / w; // 解不等式 x*(x+1)/2 <= limit // x 初始给一个偏大的估计,然后向下修正 long long x = (long long)(sqrtl((long double)2 * limit)) + 2; // 如果算小了,向上修 while ((x + 1) * (x + 2) / 2 <= limit) { x++; } // 如果算大了,向下修 while (x * (x + 1) / 2 > limit) { x--; } total += x; // 一旦够了就直接返回,既加速又防止 total 溢出 if (total >= mountainHeight) { return true; } } return false; }; long long left = 1, right = 1e18; while (left < right) { long long mid = left + (right - left) / 2; if (can(mid)) { right = mid; } else { left = mid + 1; } } return left; } };这里解释几个代码细节。
sqrtl((long double)2 * limit)用的是长双精度开方,精度比普通double高。为什么不用2.0 * t / w?因为limit本身就是t / w,直接用2 * limit更简洁,也避免再一次做大数除法。初始值故意+2,就是为了保证它大概率大于等于真实解,这样向下修正的循环次数很少。
right = 1e18是这道题原题约束下的一个安全上界。如果面对的是竞赛环境,我建议用倍增法初始化右边界,后面我会写。
二分循环里用left + (right - left) / 2而不是(left + right) / 2,是为了防止两个大数相加溢出。虽然这道题里1e18相加不会爆long long,但是这是一个通用好习惯。
3.2 Python 版本(用 isqrt 防浮点误差)
Python 版本我推荐用math.isqrt,它返回的是整数平方根,完全没有浮点误差,非常适合这种边界精确的公式:
from math import isqrt from typing import List class Solution: def minimumSeconds(self, mountainHeight: int, workerTimes: List[int]) -> int: def can(t: int) -> bool: total = 0 for w in workerTimes: # 同样先除以 w limit = t // w # 精确整数平方根,避免浮点误差 x = (isqrt(1 + 8 * limit) - 1) // 2 # 边界修正 while x * (x + 1) // 2 <= limit: x += 1 while x * (x + 1) // 2 > limit: x -= 1 total += x if total >= mountainHeight: return True return False left, right = 1, 10**18 while left < right: mid = (left + right) // 2 if can(mid): right = mid else: left = mid + 1 return left用isqrt是这个小节的一个小技巧。很多人会用int(sqrt(...)),但浮点sqrt在某些边界数字上会往回缩一位。比如sqrt(225)在 IEEE 754 标准里一定是 15,但sqrt(226)的浮点结果可能比真实值略小,取整后差一。isqrt直接给出精确值,配合边界修正,代码更稳。
Python 整数没有固定位数,理论不上不会溢出,但t // w这种做法依然值得保留,原因不只是防溢出,而是它让公式里的数字更小,计算更快。
3.3 复杂度与边界说明
这套解法的时间复杂度是O(n log R),其中n是工人数量,R是答案区间长度。这里R大约是10^18,log2(R)约等于 60,所以外层的二分只有六十轮。每一轮里要遍历所有工人,计算每个工人的最大产出。空间复杂度是O(1),没有额外数组。
对比一下堆模拟方案:如果你用最小堆,每次让一个工人多搬一块土,复杂度是O(mountainHeight * log n)。如果mountainHeight是10^5,堆模拟还勉强能跑,但如果mountainHeight到10^9,那就完全不可接受了。二分答案厉害的地方在于,它把对“高度 H”的依赖变成了对“答案范围 R”的依赖,而log R增长非常缓慢,这才是大数据下真正的解法。
关于二分上界,我再补充一个通用写法。如果你不想依赖题目约束,可以用倍增法初始化右边界:
long long left = 1, right = 1; while (!can(right)) { right *= 2; }这样right会从 1 开始翻倍,直到找到一个足够大的可行时间。好处是完全不用猜上界,坏处是如果答案非常大,right可能会爆long long。不过原题约束下没有这个问题。
4. 实战中我踩过的坑和排查技巧
4.1 一上来就写乘法,直接溢出
我写第一版代码的时候,check函数里直接写了:
if (1LL * w * x * (x + 1) / 2 <= t) ...结果在小数据样例上能过,一到大测试用例就各种奇怪错误。原因很简单:当w和x都达到10^9量级时,w * x * (x + 1)早超过long long的表示范围了,哪怕最后还要除以 2,中间的乘积已经爆炸。
正确的做法应该是先把不等式两边同时除以w。我再次强调一下这个等价关系:因为w * (x * (x + 1) / 2)是w的整数倍,所以w * X <= t等价于X <= floor(t / w),这里X = x * (x + 1) / 2。因此你只需要算:
long long limit = t / w;然后把所有判断都基于limit。这样一来,中间最大数字也只有x * (x + 1),而x的量级是sqrt(limit),即使limit是10^18,x * (x + 1)也只有10^18量级,刚好在long long安全范围内。
如果你真的担心极端情况,C++ 里可以用__int128做乘法判断,但原题完全不需要。
4.2 右边界取值太保守,答案被切掉
另一个让我折腾了一会儿的坑,是二分右边界。
我一开始觉得:最慢的工人搬一块土最多耗时max(workerTimes),山高是mountainHeight,那答案最多也就是mountainHeight * max(workerTimes)。于是把右边界设成了这个值,结果好几个测试用例直接报错。
后来我意识到,这个上界只对“每个人只搬一块土”的情况成立。如果只有一个人干活,他要搬完一整座山,总耗时是:
w * mountainHeight * (mountainHeight + 1) / 2
这个数字比mountainHeight * w大得多。举个例子,mountainHeight = 100,w = 100,那么一个人搬完 100 块土需要:
100 * 100 * 101 / 2 = 505000
而mountainHeight * w只有10000,差距巨大。所以用mountainHeight * max(workerTimes)作为右边界,会把正确答案直接切在二分区间外面。
后来我改成固定1e18,或者用倍增法找右边界,问题就消失了。这也是我推荐倍增法的原因,它不需要你事前估算答案的量级。
4.3 while边界修正写成了死循环
用公式算 x 之后,边界修正的代码也需要小心。我的一个朋友第一次写这个题,他在check里写了这样一段:
while (x * (x + 1) / 2 <= limit) { x++; } while (x * (x + 1) / 2 > limit) { x--; }单看好像没问题,但实际运行起来在某些输入下会死循环。原因在于:如果浮点初始值恰好非常接近真实解,第一个循环把x加到某个值,使得x * (x + 1) / 2刚好大于limit,然后退出;第二个循环又把x减回去,使得x * (x + 1) / 2刚好小于等于limit,然后退出。看上去没问题,但因为边界上浮点修正常常出现“x 在真实值附近来回跳动”,如果两个循环的结束条件之间隔着不止一个整数,就可能反复横跳。
我的解决办法是:初始值故意加一个固定余量,保证x一定不小于真实值,然后只用“向下修正”。这样循环从大到小逼近真实值,最多执行常数次,绝不会来回跳。
具体写法是:
long long x = (long long)(sqrtl((long double)2 * limit)) + 2; while (x * (x + 1) / 2 > limit) { x--; }如果你担心初始值偏小,就在前面加一个“向上修正”,但要确保两个循环的修正总量很小。实际浮点误差最多差 2 到 3,所以我的写法足够安全。
4.4 为什么堆模拟不是好答案
我见过有人用最小堆做这道题:把每个工人的“下一块土耗时”放进堆里,每次弹出耗时最小的工人,让他多搬一块土,然后把他的下一块土耗时压回堆里,直到总块数达到mountainHeight。这个过程在数据规模很小的时候完全正确,而且思路很直观。
但堆模拟的时间复杂度是O(mountainHeight * log n)。如果mountainHeight是10^5,大约要执行十万次堆操作,勉强能过。可一旦mountainHeight到10^6、10^7,堆模拟就彻底不行了。二分答案则完全不受mountainHeight大小影响,它的复杂度只由答案范围的对数决定。面试的时候,如果一道题的数据范围明文给出了mountainHeight可以很大,那你基本可以判断命题人希望考察的不是堆模拟,而是二分答案加数学判定。
5. 这套思路的价值不止于“移山”
5.1 抽象成资源分配模型
把“移山”这个外壳剥掉,本题其实是一个很常见的资源分配模型:有n个处理节点,第i个节点处理第k个任务需要w_i * k秒,给定总任务量H,求最短完成时间。这个模型在工程里非常常见。
举个例子,某个系统要把一份大文件拆成很多分片,分发给多个 CDN 节点缓存。每个节点处理第k个分片的时间可能因为带宽、排队等因素逐渐变长。你要算最短分发时长,就可以套用今天的二分答案思路。再比如工厂排产,一批订单分给若干条产线,每条产线做第k个订单的耗时随订单量递增,问题同样可以抽象成这个模型。
所以这道题真正值得学的东西,不是怎么证明公式,而是“把一个看起来是调度的问题,转换成给定时间判断产能的问题”。这个思维在真实系统设计里也很常用,尤其是做容量规划、性能压测、任务调度的时候,经常需要回答“给定预算时间,系统能不能完成任务”。
5.2 识别“二分答案”类题目的套路
做多了你会发现,一大批题目都有同一个套路:题目问“最小需要多少时间/次数/最大值”,你直接算很难,但给定一个候选值,你可以在O(n)内判断它是否可行,而且可行性随着候选值单调变化。一旦满足这三个条件,就可以大胆使用二分答案。
具体步骤我总结成三步。
第一步,把最优化问题改成判定问题,思考“给定 x,能不能做到”。
第二步,证明可行性随 x 单调。本题里,时间越长越容易完成,这是最直观的单调性。
第三步,确定二分边界。左边界是理论上的最小可能值,右边界是足够大的可行值,然后往返收缩。
面试时如果你能把这套话说出来,通常比直接闷头写代码更能让面试官理解你的思路。很多同学写二分答案题,卡就卡在“不知道 check 怎么写”,其实 check 的本质就是“给定参数,快速求结果”,这一点需要多练才能熟练。
5.3 留给你的变体练习
如果你看完这篇还想加深理解,我建议你试着做两个变体。
第一个变体:把规则改成“第 i 个工人移除第 k 单位高度需要workerTimes[i] * k^2秒”,你还能用二分答案做吗?如果可以,公式应该怎么改?注意这里不再是等差数列求和,而是平方和公式,但单调性没有变。
第二个变体:把规则改成“每个工人只能连续工作 x 秒后必须休息 y 秒”,单调性还存在吗?这就会引入新的复杂度,你要思考可行性判定是否还能高效完成。
我自己做完这两个变体之后,最大的感受是:二分答案框架的通用性很强,难点反而在于你有没有能力根据题目变化,快速写出对应的判定函数。
最后说点个人体会。这道题最打动我的地方,不是它的公式多优美,而是“把最优时间转成可行性判定”这个思维转换。一开始我盯着怎么给工人派活,越想越复杂;后来突然意识到,我根本不需要安排具体调度,只需要回答“给 T 秒够不够”。这个视角一旦建立,整个问题的复杂度瞬间就降下来了。如果你在面试中遇到类似的问题,可以试着先把这句话说出来:“这个问题答案有单调性,我愿意二分时间,然后快速验证。”面试官通常就会顺着你的思路走,后面的代码写起来也就顺理成章了。