引言
这题的核心在于:把“以某个吃香蕉速度 speed 是否能在 h 小时内吃完”的可行性判断,抽象为一个单调谓词,然后用二分搜索在区间 [1, max(piles)] 内找到满足条件的最小 speed。
本文将:
- 给出两种写法(递归版与迭代版)
- 详细解释「整除上取整」计算方式
- 回答两个高频问题:
Math.ceil((double)pile/speed)能否写成Math.ceil(pile/speed)?totalHours用int还是long?
- 说明循环条件
left < right的意义与不变量 - 最后对两种方案做对比与总结
题目与建模
给定香蕉堆数组 piles,以及总时长 h,小猴每小时从一堆中吃 speed 根(若不够则吃完这堆)。问:最小的 speed 是多少,才能在 h 小时内吃完所有香蕉?
关键观察:
- 若
speed可行(能在h小时内吃完),则更大的速度也一定可行(单调性)。 - 因此可在
[1, max(piles)]上做二分查找最小可行值。
判定函数(谓词):
eatAll(piles, speed, h)计算以speed吃完所有堆所需的总小时数totalHours,若totalHours <= h则可行。
解法一:递归二分
代码
class Solution {
public int minEatingSpeed(int[] piles, int h) {
int left = 1, right = 0;
for (int pile : piles) {
right = Math.max(pile, right);
}
return binarySearch(piles, h, left, right);
}
int binarySearch(int[] piles, int h, int left, int right) {
if (left == right) {
return left;
}
int mid = left + (right - left) / 2;
if (eatAll(piles, mid, h)) {
return binarySearch(piles, h, left, mid);
} else {
return binarySearch(piles, h, mid + 1, right);
}
}
boolean eatAll(int[] piles, int speed, int h) {
long totalHours = 0; // 用 long,解释见下文
for (int pile : piles) {
totalHours += Math.ceil((double) pile / speed);
if (totalHours > h) return false; // 提前剪枝
}
return true;
}
}
关键解释
- 为什么是
Math.ceil((double)pile/speed)?- 因为
pile和speed是int,写成pile / speed会先做整数除法,小数部分被截断。例如5/2 == 2,再ceil(2)仍是 2,与真实需要的 3 小时不符。 - 正确做法是先转成
double再ceil,或者用纯整数写法hours = (pile + speed - 1) / speed(见解法二)。
- 因为
Math.ceil(pile/speed)可以吗?- 不可以。原因同上:
pile/speed已经是整数结果,再ceil毫无意义,等价于向下取整,导致低估所需小时数,判定为“可行”的速度可能过小,最终答案错误。
- 不可以。原因同上:
totalHours必须是long吗?- 从上界分析:当
speed = 1时,totalHours = sum(piles)。由于piles.length ≤ 1e4且piles[i] ≤ 1e9,极端情况下总和可达1e13,超出int(约2.1e9)。 - 若用
int直接累加,可能溢出成负数,破坏比较逻辑。因此本实现用long更稳妥。 - 另一种安全做法(见解法二)是:用
int但在每次累加后立即与h比较并提前返回。因为h ≤ 1e9,一旦超过h就返回,不会让和增长到超过int上限;且在“可行”的情况下,总和也不可能超过h,因此int也安全。
- 从上界分析:当
解法二:迭代二分
代码
class Solution {
public int minEatingSpeed(int[] piles, int h) {
int left = 1, right = 0;
for (int pile : piles) {
right = Math.max(pile, right);
}
while (left < right) { // 不变量见下文
int mid = (left + right) >>> 1; // 等价于 (left+right)/2 对于非负数
if (eatAll(piles, mid, h)) {
right = mid; // mid 可行,收缩右边界,继续找更小可行值
} else {
left = mid + 1; // mid 不可行,最小可行值必在右侧
}
}
return left; // 此时 left == right,为最小可行速度
}
boolean eatAll(int[] piles, int speed, int h) {
int totalHours = 0; // 用 int 但配合提前退出,见解释
for (int pile : piles) {
// 纯整数实现的上取整:ceil(pile / speed)
totalHours += (pile + speed - 1) / speed;
if (totalHours > h) return false; // 及时剪枝,避免溢出
}
return true;
}
}
为什么循环条件是 left < right?
我们要找的是“最小可行值”。维护不变量:
- 区间始终是闭区间
[left, right] eatAll(right)一定可行(初始right = max(piles),显然可行)- 每次取
mid = floor((left + right)/2)- 若
mid可行,则最小可行值在[left, mid],令right = mid - 若
mid不可行,则最小可行值在[mid + 1, right],令left = mid + 1
- 若
用 left < right 可以保证每次循环都严格缩小区间长度(至少减 1),最终停在 left == right,即答案。若使用 left <= right,需要额外处理收敛与返回逻辑,否则容易出现死循环或越界。
关于 mid 的计算
- 代码使用
(left + right) >>> 1,对非负数与(left + right) / 2等价。 - 更常见的写法是
left + (right - left) / 2,从数学上避免left + right的潜在溢出;本题里right ≤ 1e9,left + right < 2^31,两者都安全。
整数上取整公式 (pile + speed - 1) / speed
目标是计算 ceil(pile / speed),用纯整数实现:
推导:设 a = pile,b = speed (≥1)。
[ \lceil \frac{a}{b} \rceil = \left\lfloor \frac{a + b - 1}{b} \right\rfloor ]
直观理解:把 a 先“抬高”到下一个能被 b 整除的数,再做整除。若 a 正好是 b 的倍数,不会多加;否则会多加到最近的倍数,恰好相当于上取整。
示例:a = 5, b = 2
ceil(5/2) = 3(5 + 2 - 1) / 2 = 6 / 2 = 3
溢出性:本题 a ≤ 1e9, b ≤ max(piles) ≤ 1e9,有 a + b - 1 ≤ 2e9 - 1 < 2^31 - 1,因此用 int 不会溢出。
小例子走一遍(piles = [3,6,7,11], h = 8)
观察速度与总小时数(使用 (pile + speed - 1) / speed 计算):
| speed | 每堆所需小时 | 总小时 |
|---|---|---|
| 4 | 1, 2, 2, 3 | 8 |
| 3 | 1, 2, 3, 4 | 10 |
speed=4 可行,speed=3 不可行,于是答案是 4。二分会在 [1, 11] 上收缩并落在 4。
两种实现对比
- 可行性判定:两者一致,核心是“整除上取整”。
- 表达方式:
- 递归版语义直观,但需要注意递归深度(本题最多 ~30 层,安全)。
- 迭代版更常见,无栈开销,循环式不变量清晰。
- 数值类型:
- 递归版用
long totalHours,即使不提前返回也不会溢出,稳健。 - 迭代版用
int totalHours搭配“每次累加后立即与h比较并提前退出”,在h ≤ 1e9的约束下同样安全且更高效。
- 递归版用
- 小技巧:
- 推荐统一用整数上取整
(pile + speed - 1) / speed,避免浮点误差与类型转换。
- 推荐统一用整数上取整
复杂度(两者相同):
- 时间:
O(n log max(piles)) - 空间:递归版
O(log max(piles)),迭代版O(1)
常见易错点
- 写成
Math.ceil(pile/speed)(错):/已经做了整数除法。 - 使用
left <= right配合错误的边界更新,导致死循环或错过答案。 mid写成(left + right)/2在极限条件下可能溢出(本题不至于,但习惯上推荐left + (right-left)/2)。totalHours用int且不提前判断> h,可能溢出。
结论与推荐
- 建议实现:迭代二分 + 纯整数上取整
(pile + speed - 1) / speed+ 每次累加后立刻与h比较提前返回。 - 若使用浮点:务必写成
Math.ceil((double)pile/speed),不要忽略强制类型转换。 totalHours:- 若无提前返回,建议
long; - 若每次累加后立即与
h比较并提前退出,int也安全(因h ≤ 1e9)。
- 若无提前返回,建议
这样写不仅正确而且在工程上更稳健、可读性也更好。