题目
你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。
如果一个蛋摔碎了,就不能再把它扔下去了。
如果鸡蛋没摔坏,就还可以继续扔!
存在楼层 F ,满足 0 <= F <= N,
任何从高于 F 的楼层落下的鸡蛋都会摔碎,
从 F 楼层或比它低的楼层落下的鸡蛋都不会破。
每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。
你的目标是:确切地知道 F 的值是多少。
无论 F 的初始值如何,你确定 F 的值的所需要的最小移动次数是多少?
示例
示例 1:
输入:K = 1, N = 2
输出:2
解释:
鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。
否则,鸡蛋从 2 楼掉落。如果它碎了,我们肯定知道 F = 1 。
如果它没碎,那么我们肯定知道 F = 2 。
因此,在最坏的情况下我们需要移动 2 次以确定 F 是多少。
示例 2:
输入:K = 2, N = 6
输出:3
示例 3:
输入:K = 3, N = 14
输出:4
提示:
1 <= K <= 100
1 <= N <= 10000
来源:力扣(LeetCode)887.鸡蛋掉落
链接:https://leetcode-cn.com/problems/super-egg-drop
分析
也就是让你找: 摔不碎鸡蛋的最高楼层 F
,
但什么叫「最坏情况」下「至少」要扔几次呢?
假如:现在先不管鸡蛋个数的限制,有 8 层楼,你怎么去找鸡蛋恰好摔碎的那层楼?
最原始的方式就是线性扫描:我先在 1 楼扔一下,没碎,我再去 2 楼扔一下,没碎,我再去 3 楼……
以这种策略,最坏情况应该就是我试到第 8 层鸡蛋也没碎(F = 8
),也就是我扔了 8 次鸡蛋。
假如:只给你 1 个鸡蛋,8 层楼,
你如果敢用二分的话,直接去第 4 层扔一下,如果鸡蛋没碎还好,
但如果碎了,你就没有鸡蛋继续测试了,无法确定鸡蛋恰好摔不碎的楼层 F
了。
这种情况下只能用线性扫描的方法,算法返回结果应该是 8。
我们选择在第 i
层楼扔了鸡蛋之后,可能出现两种情况:
1. 鸡蛋碎了,
2. 鸡蛋没碎。
如果鸡蛋碎了,那么鸡蛋的个数 K
应该减一,搜索的楼层区间应该从 [1..N]
变为 [1..i-1]
共 i-1
层楼;
如果鸡蛋没碎,那么鸡蛋的个数 K
不变,搜索的楼层区间应该从 [1..N]
变为 [i+1..N]
共 N-i
层楼。
因为我们要求的是最坏情况下扔鸡蛋的次数,
所以鸡蛋在第 i
层楼 碎没碎,取决于哪种情况的结果更大
解法1(超出时间限制)
// 语言:C, 超出时间限制
// 提示说了,egg最多100个,楼层最多10000层
int arr[101][10001] = {0};
int superEggDrop(int eggNum, int totalFloor){
// 只有一个鸡蛋时,只能第一层开始,逐层往上试
if (eggNum == 1) {
return totalFloor;
}
// 0层, 无需实验
if (totalFloor <= 0) {
return 0;
}
// 充顶、最多也就是线性查找,逐层试的情况
int result = totalFloor;
// 先从备忘录中取结果,如果有记录,则直接返回
if (arr[eggNum][totalFloor]) {
return arr[eggNum][totalFloor];
}
// 开始
for (int k = 1; k <= totalFloor; k++) {
// 在第k层扔了一次
int testNum = 1;
// 结果之一是: 在第k层,鸡蛋碎了: 鸡蛋少一个,后面要测的楼层范围也减少
int caseA_eggBroken = superEggDrop(eggNum - 1, k - 1);
// 结果之二是: 在第k层,鸡蛋没碎: 鸡蛋数不变,后面要测的楼层范围是totalFloor - k
int caseB_eggOk = superEggDrop(eggNum, totalFloor - k);
// 最坏的情况,那么就是caseA 和 caseB较大的情况; 此时测试的次数最多
int tmpResult = testNum + maxFunc(caseA_eggBroken, caseB_eggOk);
// 至少要扔多少次, 取较小的那个
result = minFunc(result, tmpResult);
}
// 备忘录用起来
arr[eggNum][totalFloor] = result;
return result;
}
运行结果如下:超时了
这个算法的时间复杂度是多少?
动态规划算法的时间复杂度就是 子问题个数 × 函数本身的复杂度。
函数本身的复杂度就是忽略递归部分的复杂度,
这里 dp
函数中有一个 for 循环,所以函数本身的复杂度是 O(N)。
子问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。
所以算法的总时间复杂度是 O(K*N^2), 空间复杂度 O(KN)。
分析
上面的for循环之所以能用二分搜索,
是因为状态转移方程的函数图像具有单调性,可以快速找到最值。
根据 dp(K, N)
数组的定义(有 K
个鸡蛋面对 N
层楼,最少需要扔几次),
很容易知道 K
固定时,这个函数一定是单调递增的,
无论你策略多优秀,楼层增加测试次数一定要增加。
那么注意 dp(K - 1, i - 1)
和 dp(K, N - i)
这两个函数,
其中 i
是从 1 到 N
单增的,
如果我们固定 K
和 N
,把这两个函数看做关于 i
的函数,
前者 dp(K - 1, i - 1)
随着 i
的增加应该是单调递增的,
而后者dp(K, N - i)
随着 i
的增加应该是单调递减的
先求二者的较大值,再求这些最大值之中的最小值,其实就是求这个交点
解法2:用二分查找代替for循环
// 通过语言:c
// 提示说了:egg最多100个,楼层最多10000层
int arr[101][10001] = {0};
int minFunc(int a, int b) {
if (a > b) {
return b;
} else {
return a;
}
}
int superEggDrop(int eggNum, int totalFloor){
// 只有一个鸡蛋时,只能第一层开始,逐层往上试
if (eggNum == 1) {
return totalFloor;
}
// 0层, 无需实验
if (totalFloor <= 0) {
return 0;
}
// 充顶、最多也就是线性查找,逐层试
int result = totalFloor;
// 先从备忘录中取结果,如果有记录,则直接返回
if (arr[eggNum][totalFloor]) {
return arr[eggNum][totalFloor];
}
// 开始
int low = 1;
int high = totalFloor;
// 1表示扔出了一次
int testNum = 1;
while (low <= high) {
// 用二分法, 直接跑到第mid层扔鸡蛋
int mid = low + (high - low) / 2;
// 如果鸡蛋碎了: 鸡蛋少一个, 后面要测的楼层范围也减少
int caseA_eggBroken = superEggDrop(eggNum - 1, mid - 1);
// 如果鸡蛋没碎: 鸡蛋数不变, 后面要测的楼层范围是totalFloor - mid
int caseB_eggOk = superEggDrop(eggNum, totalFloor - mid);
// 最坏的情况, 就是求较大值, 测试次数较多的情况
if (caseA_eggBroken > caseB_eggOk) {
// 如果鸡蛋碎了, 表示F层应该在mid层的下面???
high = mid - 1;
result = minFunc(result, caseA_eggBroken + testNum);
} else {
// 如果鸡蛋没有碎, 表示F层应该在mid层的上面???
low = mid + 1;
result = minFunc(result, caseB_eggOk + testNum);
}
}
// 备忘录用起来
arr[eggNum][totalFloor] = result;
return result;
}