在这篇文章中,我们将深入探讨一个在算法面试中非常经典且具有挑战性的问题——通常被称为“Koko 吃香蕉”问题。这是一个典型的二分查找应用场景,但它隐藏得非常巧妙,以至于第一眼看过去,你可能很难将其与二分法联系起来。
通过阅读这篇文章,你将学会如何识别这类“在单调区间上寻找最小/最大值”的问题模式,掌握如何用二分查找来替代低效的暴力搜索,并深入理解代码优化的每一个细节。无论你是正在准备面试,还是想提升自己的算法思维能力,这篇指南都会为你提供从思路到实现的全方位解析。
问题描述
首先,让我们明确一下我们面临的挑战。假设我们有 $N$ 堆香蕉,每堆的数量各不相同,存储在一个数组 INLINECODEab3e4b94 中。同时,我们有一个整数 INLINECODE4451fc25,表示 Koko 必须吃完所有香蕉的总时限(小时数)。
核心规则如下:
- 进食速度:Koko 每小时可以选择吃掉 $x$ 个香蕉。
- 单堆限制:在一小时内,Koko 只能选择一堆来吃。如果这一堆的香蕉数量少于 $x$,她吃完这一堆就停止,不会去吃另一堆剩下的(即使这一小时内还有时间)。
- 目标:我们需要找到一个最小的进食速度 $x$,使得 Koko 能在 $k$ 小时内(或更少)吃完所有的香蕉。
如果这个速度 $x$ 太慢,她就没法在规定时间内吃完;如果 $x$ 太快,虽然能吃完,但这不是我们要找的“最优解”。我们的目标是找到那个刚刚好的最小值。
示例解析
为了让你更直观地理解,让我们通过几个具体的例子来看看这个问题是如何运作的。
#### 示例 1:基础场景
> 输入:INLINECODE2eeb960e, INLINECODEbe3e9463
> 输出:5
解释:
我们可以看到数组中最大的堆是 10。Koko 需要在 4 小时内吃完。
- 如果速度是 5:
* 第一堆(5个):正好 1 小时吃完。
* 第二堆(10个):需要 $10 / 5 = 2$ 小时。
* 第三堆(3个):小于 5,所以 1 小时吃完。
* 总时间:$1 + 2 + 1 = 4$ 小时。符合要求!
如果尝试速度 4 呢?
- 第一堆:$5/4
ightarrow 2$ 小时(向上取整)。 - 第二堆:$10/4
ightarrow 3$ 小时。 - 第三堆:$3/4
ightarrow 1$ 小时。 - 总时间:$2 + 3 + 1 = 6$ 小时。超过了 $k=4$,所以速度太慢了。
因此,5 是正确答案。
#### 示例 2:更复杂的分布
> 输入:INLINECODE51148e02, INLINECODE7e26ea8e
> 输出:10
解释:
- 如果速度是 10:
* 第一堆(5):1 小时。
* 第二堆(10):1 小时。
* 第三堆(15):2 小时($15/10$ 向上取整)。
* 第四堆(20):2 小时。
* 总时间:$1+1+2+2 = 6$ 小时。$6 \le 7$,可行。
解题思路探索
面对这个问题,我们通常有两种思路:一种是直接粗暴的尝试,另一种则是利用数学特性的优化路径。
#### 思路一:暴力解法(线性搜索)
“如果迷茫,那就先把最笨的方法写出来。”
最直观的想法是:既然我们要找最小的 $x$,那为什么不从 1 开始,一个一个试呢?
- 我们知道 $x$ 的取值范围至少是 1,至多是数组中最大的那个数(因为如果速度等于最大堆的大小,Koko 每小时最多吃一堆,肯定能吃完)。
- 我们从 INLINECODEddb83b51 开始遍历,直到 INLINECODEd638b3c6。
- 对于每一个
x,我们计算吃完所有堆需要的总时间。 - 一旦找到第一个满足 INLINECODEc9ccc61d 的 INLINECODE044a7880,立刻返回,因为它就是最小的可行速度。
计算时间的关键技巧:
在代码中,计算吃掉一堆香蕉的时间不能简单地用整数除法。例如,3 个香蕉每小时吃 5 个,需要 1 小时;7 个香蕉每小时吃 5 个,需要 2 小时。这本质上是一个向上取整的操作。
公式为:$\text{time} = \lceil \text{pile} / x \rceil$
在编程中,为了避免使用浮点数运算,我们通常使用这个技巧:
$$ \text{time} = (\text{pile} + x – 1) / x $$
为什么这样做?想象一下除法的余数。如果 INLINECODEf4652bba 除以 INLINECODE0dc0bbc0 有余数,加上 x-1 之后就一定会让商进位。
让我们来看看暴力解法的具体实现。
暴力解法代码实现
这种解法的时间复杂度是 $O(N \times M)$,其中 $N$ 是堆的数量,$M$ 是堆中香蕉的最大数量(即我们的搜索范围)。虽然对于小数据量没问题,但当香蕉数量极大时(比如 $10^9$),效率会非常低。
#### C++ 实现
#include
#include
#include
using namespace std;
// 函数:计算 Koko 吃香蕉的最小速度(暴力解法)
int kokoEat(vector& arr, int k) {
// 1. 找到最大的堆,这是搜索上界
// 因为如果速度等于最大堆的大小,每堆最多花1小时,肯定能在N小时内吃完
int mx = *max_element(arr.begin(), arr.end());
// 2. 从速度 1 开始线性尝试每一个可能的速度
for (int speed = 1; speed <= mx; speed++) {
long long reqTime = 0;
// 3. 计算当前速度下吃完所有堆需要的时间
for (int i = 0; i < arr.size(); i++) {
// 加上以当前速度吃完这一堆所需的时间
// 使用 (arr[i] + speed - 1) / speed 来实现向上取整
reqTime += (arr[i] + speed - 1) / speed;
}
// 4. 如果总时间在允许的小时数内,返回当前速度
// 因为我们是从 1 开始递增的,找到的第一个满足条件的解就是最小的
if (reqTime <= k) {
return speed;
}
}
// 理论上上面的循环一定会 return,这里是保底逻辑
return mx;
}
int main() {
// 测试用例
vector arr = {5, 10, 3};
int k = 4;
// 调用函数
int minSpeed = kokoEat(arr, k);
cout << "Koko 吃香蕉的最小速度应为: " << minSpeed << endl;
return 0;
}
#### Java 实现
import java.util.Arrays;
class GfG {
static int kokoEat(int[] arr, int k) {
// 找到数组中的最大值,作为线性搜索的右边界
int mx = Arrays.stream(arr).max().getAsInt();
// 遍历每一个可能的速度,从 1 开始
for (int speed = 1; speed <= mx; speed++) {
long reqTime = 0;
// 遍历每一堆香蕉
for (int i = 0; i < arr.length; i++) {
// 计算吃掉当前这堆所需的时间(向上取整)
// (arr[i] + speed - 1) / speed 等同于 Math.ceil((double)arr[i] / speed)
reqTime += (arr[i] + speed - 1) / speed;
}
// 如果当前速度下的总进食时间小于或等于给定的时间 k
if (reqTime <= k) {
return speed;
}
}
return mx;
}
public static void main(String[] args) {
int[] arr = {5, 10, 3};
int k = 4;
System.out.println(kokoEat(arr, k));
}
}
#### Python 实现
def kokoEat(arr, k):
# 获取最大堆的数量作为搜索上限
mx = max(arr)
# 从 1 到 mx 逐个尝试速度
for speed in range(1, mx + 1):
reqTime = 0
# 计算总时间
for i in range(len(arr)):
# Python 中使用整除 //
# (arr[i] + speed - 1) // speed 实现了向上取整的逻辑
reqTime += (arr[i] + speed - 1) // speed
# 检查是否满足条件
if reqTime <= k:
return speed
return mx
if __name__ == "__main__":
# 测试数据
arr = [5, 10, 3]
k = 4
print(f"计算结果: {kokoEat(arr, k)}")
#### C# 实现
using System;
using System.Linq;
class GfG {
static int kokoEat(int[] arr, int k) {
// 使用 LINQ 快速获取最大值
int mx = arr.Max();
// 线性搜索每一个速度
for (int speed = 1; speed <= mx; speed++){
long reqTime = 0;
for (int i = 0; i < arr.Length; i++) {
// 核心计算逻辑:向上取整
// 注意:这里使用 long 防止大数相加溢出
reqTime += (arr[i] + speed - 1) / speed;
}
// 判断是否满足时间限制
if (reqTime <= k) {
return speed;
}
}
return mx;
}
static void Main() {
int[] arr = {5, 10, 3};
int k = 4;
// 输出结果
Console.WriteLine("最小速度: " + kokoEat(arr, k));
}
}
性能分析与优化方向
虽然暴力解法逻辑清晰,但它有一个致命的弱点:效率。
假设最大的堆有 $10^9$ 个香蕉(这在现实中可能不常见,但在算法测试中是可能的)。我们的循环就需要运行 $10^9$ 次。这显然是无法接受的。
你可能会问:有没有更聪明的方法?
当然有。让我们观察一下“进食速度”与“所需总时间”之间的关系:
- 如果速度 $x$ 变大,所需时间一定变少(或不变)。
- 如果速度 $x$ 变小,所需时间一定变多。
这种关系被称为单调性。在单调性问题中寻找特定值,二分查找 是我们手中的“神兵利器”。
预期解法:对答案使用二分查找
我们可以将二分查找应用在答案(即速度 $x$)上。
算法步骤:
- 确定范围:
* 左边界 left = 1(每小时至少吃 1 个)。
* 右边界 right = max(arr)(每小时吃最多的那一堆的数量)。
- 二分收缩:
* 取中间值 mid = (left + right) / 2 作为当前测试的速度 $x$。
* 计算 $x = mid$ 时需要的总时间。
* 判断:
* 如果 总时间 <= k:说明速度够了,可能还有更小的速度也能行。我们将搜索范围缩小到左半部分(INLINECODEc176981f),并记录当前的 INLINECODEe2dd0fc8 为潜在答案。
* 如果 总时间 > k:说明速度太慢了,必须加快速度。我们将搜索范围缩小到右半部分(left = mid + 1)。
- 结束条件:当
left >= right时,我们就找到了最小的可行速度。
这种方法将时间复杂度从 $O(N \times M)$ 降低到了 $O(N \times \log M)$。对于 $M = 10^9$ 的情况,我们只需要大约 30 次循环就能找到答案!
关键要点与实战建议
- 单调性是关键:在解决算法问题时,一旦你发现变量之间存在“越大越好”或“越小越好”的单调关系,就要立刻想到二分查找。这里的变量是“速度”,属性是“速度越大,时间越短”。
- 注意整数溢出:在计算总时间时,如果数组很大且每堆的香蕉很多,INLINECODE196c96bf 的总和可能会超过 INLINECODEe1d579ab 的范围(约 $2 \times 10^9$)。因此,在 C++、Java 或 C# 中,务必使用 INLINECODE76eb6d3a (C++/Java/C#) 或 INLINECODE051da2b5 类型来存储累加的时间,否则你会在测试用例中遇到莫名其妙的“Wrong Answer”。
- 向上取整的技巧:牢记 INLINECODEa447219a 这个整数运算技巧。它比调用 INLINECODEb6106c1c 函数要快得多,而且避免了浮点数可能带来的精度问题。
- 边界条件的处理:在二分查找中,如何移动 INLINECODE62f9c60e 和 INLINECODEb830a479 指针至关重要。
* 如果 INLINECODE6e9fab16,我们设 INLINECODE52025b77(因为 mid 本身可能就是答案,不能排除)。
* 如果 INLINECODEd93d5a64,我们设 INLINECODEca1086eb(因为 mid 肯定不行,排除它)。
结语
从“Koko 吃香蕉”这个问题中,我们不仅学到了如何解决一个具体的编程难题,更重要的是掌握了一种通用的算法思维模式:在连续的搜索空间中利用单调性进行二分查找。
下次当你面对一个看似需要 $O(N^2)$ 或者 $O(N \times M)$ 的问题时,不妨停下来思考一下:
- 答案是否落在一个有序的区间内?
- 我能不能先“猜”一个中间值,然后根据反馈丢弃一半的错误答案?
希望这篇文章能帮助你在算法学习的道路上更进一步。祝你编码愉快!